- move pr collection into npm module

(publish without any furhter notes or docs)
- refactor action to use npm dependency
This commit is contained in:
Mike Penz
2023-06-03 11:22:52 +00:00
committed by GitHub
parent 4a9ea3cd6a
commit e7dc26611c
28 changed files with 3529 additions and 1808 deletions
+1322
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
{
"name": "github-pr-collector",
"version": "v1.0.0",
"description": "Library to fetch GitHub pull request between 2 tags/sha1 hashes.",
"main": "lib/prCollector.js",
"types": "lib/prCollector.d.ts",
"scripts": {
"build": "tsc",
"format": "prettier --write **/*.ts",
"format-check": "prettier --check **/*.ts",
"lint": "eslint src/**/*.ts",
"package": "ncc build --source-map --license licenses.txt",
"test": "jest --passWithNoTests",
"all": "npm run build && npm run format && npm run lint && npm run package && npm test"
},
"repository": {
"type": "git",
"url": "git+https://github.com/mikepenz/release-changelog-builder.git"
},
"keywords": [
"github",
"actions",
"changelog",
"release-notes",
"release",
"notes",
"change",
"release-automation",
"pull-requests",
"issues",
"labels"
],
"author": "Mike Penz",
"license": "Apache 2.0",
"dependencies": {
"@actions/core": "^1.10.0",
"@actions/exec": "^1.1.1",
"@actions/github": "^5.1.1",
"@octokit/rest": "^19.0.11",
"https-proxy-agent": "^7.0.0",
"moment": "^2.29.4",
"semver": "^7.5.1",
"webpack": "^5.85.0"
},
"devDependencies": {
"@types/node": "^20.2.5",
"@types/semver": "^7.5.0",
"@vercel/ncc": "^0.36.1",
"js-yaml": "^4.1.0",
"prettier": "2.8.8",
"typescript": "^5.1.3"
}
}
+204
View File
@@ -0,0 +1,204 @@
import * as core from '@actions/core'
import {Octokit, RestEndpointMethodTypes} from '@octokit/rest'
import moment from 'moment'
import {failOrError} from './utils'
import {PullRequestInfo} from './pullRequests'
import {Options} from './prCollector'
export interface DiffInfo {
changedFiles: number
additions: number
deletions: number
changes: number
commits: number
commitInfo: CommitInfo[]
}
export const DefaultDiffInfo: DiffInfo = {
changedFiles: 0,
additions: 0,
deletions: 0,
changes: 0,
commits: 0,
commitInfo: []
}
export interface CommitInfo {
sha: string
summary: string
message: string
author: string
date: moment.Moment
}
export class Commits {
constructor(private octokit: Octokit) {}
async getDiff(owner: string, repo: string, base: string, head: string): Promise<DiffInfo> {
const diff: DiffInfo = await this.getDiffRemote(owner, repo, base, head)
diff.commitInfo = this.sortCommits(diff.commitInfo)
return diff
}
private async getDiffRemote(owner: string, repo: string, base: string, head: string): Promise<DiffInfo> {
let changedFilesCount = 0
let additionCount = 0
let deletionCount = 0
let changeCount = 0
let commitCount = 0
// 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
// eslint-disable-next-line no-constant-condition
while (true) {
const compareResult = await this.octokit.repos.compareCommits({
owner,
repo,
base,
head: compareHead
})
if (compareResult.data.total_commits === 0) {
break
}
changedFilesCount += compareResult.data.files?.length ?? 0
const files = compareResult.data.files
if (files !== undefined) {
for (const file of files) {
additionCount += file.additions
deletionCount += file.deletions
changeCount += file.changes
}
}
commitCount += compareResult.data.commits.length
commits = compareResult.data.commits.concat(commits)
compareHead = `${commits[0].sha}^`
}
core.info(`️ Found ${commits.length} commits from the GitHub API for ${owner}/${repo}`)
return {
changedFiles: changedFilesCount,
additions: additionCount,
deletions: deletionCount,
changes: changeCount,
commits: commitCount,
commitInfo: commits
.filter(commit => commit.sha)
.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
}
async getCommitHistory(options: Options): Promise<DiffInfo> {
const {owner, repo, fromTag, toTag, failOnError} = options
core.info(`️ Comparing ${owner}/${repo} - '${fromTag.name}...${toTag.name}'`)
const commitsApi = new Commits(this.octokit)
let diffInfo: DiffInfo
try {
diffInfo = await commitsApi.getDiff(owner, repo, fromTag.name, toTag.name)
} catch (error) {
failOrError(`💥 Failed to retrieve - Invalid tag? - Because of: ${error}`, failOnError)
return DefaultDiffInfo
}
if (diffInfo.commitInfo.length === 0) {
core.warning(`⚠️ No commits found between - ${fromTag.name}...${toTag.name}`)
return DefaultDiffInfo
}
return diffInfo
}
async generateCommitPRs(options: Options): Promise<[DiffInfo, PullRequestInfo[]]> {
const {owner, repo, configuration} = options
const diffInfo = await this.getCommitHistory(options)
const commits = diffInfo.commitInfo
if (commits.length === 0) {
return [diffInfo, []]
}
const prCommits = filterCommits(commits, configuration.exclude_merge_branches)
core.info(`️ Retrieved ${prCommits.length} commits for ${owner}/${repo}`)
const prs = prCommits.map(function (commit): PullRequestInfo {
return {
number: 0,
title: commit.summary,
htmlURL: '',
baseBranch: '',
createdAt: commit.date,
mergedAt: commit.date,
mergeCommitSha: commit.sha,
author: commit.author || '',
repoName: '',
labels: [],
milestone: '',
body: commit.message || '',
assignees: [],
requestedReviewers: [],
approvedReviewers: [],
status: 'merged'
}
})
return [diffInfo, prs]
}
}
/**
* Filters out all commits which match the exclude pattern
*/
export function filterCommits(commits: CommitInfo[], excludeMergeBranches: string[]): CommitInfo[] {
const filteredCommits = []
for (const commit of commits) {
if (excludeMergeBranches) {
let matched = false
for (const excludeMergeBranch of excludeMergeBranches) {
if (commit.summary.includes(excludeMergeBranch)) {
matched = true
break
}
}
if (matched) {
continue
}
}
filteredCommits.push(commit)
}
return filteredCommits
}
+55
View File
@@ -0,0 +1,55 @@
export interface Configuration {
max_tags_to_fetch: number
max_pull_requests: number
max_back_track_time_days: number
exclude_merge_branches: string[]
sort: Sort | string // "ASC" or "DESC"
tag_resolver: TagResolver
base_branches: string[]
}
/**
* Defines the properties of the PullRequestInfo useable in different configurations
*/
export type Property =
| 'number'
| 'title'
| 'branch'
| 'author'
| 'labels'
| 'milestone'
| 'body'
| 'assignees'
| 'requestedReviewers'
| 'approvedReviewers'
| 'status'
export interface Rule extends Regex {
on_property?: Property // retrieve the property to apply the rule on
}
export interface Sort {
order: 'ASC' | 'DESC' // the sorting order
on_property: 'mergedAt' | 'title' // the property to sort on. (mergedAt falls back to createdAt)
}
export interface TagResolver {
method: string // semver, sort
filter?: Regex // the regex to filter the tags, prior to sorting
transformer?: Transformer // transforms the tag name using the regex, run after the filter
}
export interface Regex {
pattern: string // the regex pattern to match
flags?: string // the regex flag to use for RegExp
}
export interface Transformer extends Regex {
target?: string // the target string to transform the source string using the regex to
}
export interface Extractor extends Transformer {
on_property?: Property[] | Property | undefined // retrieve the property to extract the value from
method?: 'replace' | 'match' | undefined // the method to use to extract the value, `match` will not use the `target` property
on_empty?: string | undefined // in case the regex results in an empty string, this value is gonna be used instead (only for label_extractor currently)
}
+74
View File
@@ -0,0 +1,74 @@
import * as exec from '@actions/exec'
import * as io from '@actions/io'
import {directoryExistsSync} from './utils'
export async function createCommandManager(workingDirectory: string): Promise<GitCommandManager> {
return await GitCommandManager.createCommandManager(workingDirectory)
}
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()
}
async initialCommit(): Promise<string> {
const revListOutput = await this.execGit(['rev-list', '--max-parents=0', 'HEAD'])
return revListOutput.stdout.trim()
}
async tagCreation(tagName: string): Promise<string> {
const creationDate = await this.execGit(['for-each-ref', '--format="%(creatordate:rfc)"', `refs/tags/${tagName}`])
return creationDate.stdout.trim().replace(/"/g, '')
}
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
}
+159
View File
@@ -0,0 +1,159 @@
import * as core from '@actions/core'
import {Configuration} from './configuration'
import {Octokit} from '@octokit/rest'
import {TagInfo, Tags} from './tags'
import {failOrError} from './utils'
import {HttpsProxyAgent} from 'https-proxy-agent'
import {PullRequestInfo, PullRequests} from './pullRequests'
import {Commits, DiffInfo} from './commits'
export interface Options {
owner: string // the owner of the repository
repo: string // the repository
fromTag: TagInfo // the tag/ref to start from
toTag: TagInfo // the tag/ref up to
includeOpen: boolean // defines if we should also fetch open pull requests
failOnError: boolean // defines if we should fail the action in case of an error
fetchReviewers: boolean // defines if the action should fetch the reviewers for PRs - approved reviewers are not included in the default PR listing
fetchReleaseInformation: boolean // defines if the action should fetch the release information for the from and to tag - e.g. the creation date for the associated release
fetchReviews: boolean // defines if the action should fetch the reviews for the PR.
commitMode: boolean // defines if we use the alternative commit based mode. note: this is only partially supported
configuration: Configuration // the configuration as defined in `configuration.ts`
}
export interface Data {
diffInfo: DiffInfo
mergedPullRequests: PullRequestInfo[]
options: Options
}
export class PullRequestCollector {
constructor(
private baseUrl: string | null,
private token: string | null,
private repositoryPath: string,
private owner: string,
private repo: string,
private fromTag: string | null,
private toTag: string | null,
private includeOpen: boolean = false,
private failOnError: boolean,
private ignorePreReleases: boolean,
private fetchReviewers: boolean = false,
private fetchReleaseInformation: boolean = false,
private fetchReviews: boolean = false,
private commitMode: boolean = false,
private configuration: Configuration
) {}
async build(): Promise<Data | null> {
// check proxy setup for GHES environments
const proxy = process.env.https_proxy || process.env.HTTPS_PROXY
const noProxy = process.env.no_proxy || process.env.NO_PROXY
let noProxyArray: string[] = []
if (noProxy) {
noProxyArray = noProxy.split(',')
}
// load octokit instance
const octokit = new Octokit({
auth: `token ${this.token || process.env.GITHUB_TOKEN}`,
baseUrl: `${this.baseUrl || 'https://api.github.com'}`
})
if (proxy) {
const agent = new HttpsProxyAgent(proxy)
octokit.hook.before('request', options => {
if (noProxyArray.includes(options.request.hostname)) {
return
}
options.request.agent = agent
})
}
// ensure proper from <-> to tag range
core.startGroup(`🔖 Resolve tags`)
const tagsApi = new Tags(octokit)
const tagRange = await tagsApi.retrieveRange(
this.repositoryPath,
this.owner,
this.repo,
this.fromTag,
this.toTag,
this.ignorePreReleases,
this.configuration.max_tags_to_fetch,
this.configuration.tag_resolver
)
let thisTag = tagRange.to
if (!thisTag) {
failOrError(`💥 Missing or couldn't resolve 'toTag'`, this.failOnError)
return null
} else {
core.setOutput('toTag', thisTag.name)
core.debug(`Resolved 'toTag' as ${thisTag.name}`)
}
let previousTag = tagRange.from
if (previousTag == null) {
failOrError(`💥 Unable to retrieve previous tag given ${this.toTag}`, this.failOnError)
return null
}
core.setOutput('fromTag', previousTag.name)
core.debug(`fromTag resolved via previousTag as: ${previousTag.name}`)
if (this.fetchReleaseInformation) {
// load release information from the GitHub API
core.info(`️ Fetching release information was enabled`)
thisTag = await tagsApi.fillTagInformation(this.repositoryPath, this.owner, this.repo, thisTag)
previousTag = await tagsApi.fillTagInformation(this.repositoryPath, this.owner, this.repo, previousTag)
} else {
core.debug(`️ Fetching release information was disabled`)
}
core.endGroup()
const options = {
owner: this.owner,
repo: this.repo,
fromTag: previousTag,
toTag: thisTag,
includeOpen: this.includeOpen,
failOnError: this.failOnError,
fetchReviewers: this.fetchReviewers,
fetchReleaseInformation: this.fetchReleaseInformation,
fetchReviews: this.fetchReviews,
commitMode: this.commitMode,
configuration: this.configuration
}
return await pullData(octokit, options)
}
}
export async function pullData(octokit: Octokit, options: Options): Promise<Data | null> {
let mergedPullRequests: PullRequestInfo[]
let diffInfo: DiffInfo
const commitsApi = new Commits(octokit)
if (!options.commitMode) {
core.startGroup(`🚀 Load pull requests`)
const pullRequestsApi = new PullRequests(octokit, commitsApi)
const [info, prs] = await pullRequestsApi.getMergedPullRequests(options)
mergedPullRequests = prs
diffInfo = info
} else {
core.startGroup(`🚀 Load commit history`)
core.info(`⚠️ Executing experimental commit mode`)
const [info, prs] = await commitsApi.generateCommitPRs(options)
mergedPullRequests = prs
diffInfo = info
}
core.endGroup()
return {
diffInfo,
mergedPullRequests,
options
}
}
+365
View File
@@ -0,0 +1,365 @@
import * as core from '@actions/core'
import {Octokit, RestEndpointMethodTypes} from '@octokit/rest'
import {Unpacked} from './utils'
import moment from 'moment'
import {Property, Sort} from './configuration'
import {Commits, DiffInfo, filterCommits} from './commits'
import {Options} from './prCollector'
export interface PullRequestInfo {
number: number
title: string
htmlURL: string
baseBranch: string
branch?: string
createdAt: moment.Moment
mergedAt: moment.Moment | undefined
mergeCommitSha: string
author: string
repoName: string
labels: string[]
milestone: string
body: string
assignees: string[]
requestedReviewers: string[]
approvedReviewers: string[]
reviews?: CommentInfo[]
status: 'open' | 'merged'
}
export interface CommentInfo {
id: number
htmlURL: string
submittedAt: moment.Moment | undefined
author: string
body: string
state: string | undefined
}
export const EMPTY_COMMENT_INFO: CommentInfo = {
id: 0,
htmlURL: '',
submittedAt: undefined,
author: '',
body: '',
state: undefined
}
type PullData = RestEndpointMethodTypes['pulls']['get']['response']['data']
type PullsListData = RestEndpointMethodTypes['pulls']['list']['response']['data']
type PullReviewsData = RestEndpointMethodTypes['pulls']['listReviews']['response']['data']
export class PullRequests {
constructor(private octokit: Octokit, private commits: Commits) {}
async getSingle(owner: string, repo: string, prNumber: number): Promise<PullRequestInfo | null> {
try {
const {data} = await this.octokit.pulls.get({
owner,
repo,
pull_number: prNumber
})
return mapPullRequest(data)
} catch (e: any /* eslint-disable-line @typescript-eslint/no-explicit-any */) {
core.warning(`⚠️ Cannot find PR ${owner}/${repo}#${prNumber} - ${e.message}`)
return null
}
}
async getBetweenDates(
owner: string,
repo: string,
fromDate: moment.Moment,
toDate: moment.Moment,
maxPullRequests: number
): Promise<PullRequestInfo[]> {
const mergedPRs: PullRequestInfo[] = []
const options = this.octokit.pulls.list.endpoint.merge({
owner,
repo,
state: 'closed',
sort: 'merged',
per_page: `${Math.min(100, maxPullRequests)}`,
direction: 'desc'
})
for await (const response of this.octokit.paginate.iterator(options)) {
const prs: PullsListData = response.data as PullsListData
for (const pr of prs.filter(p => !!p.merged_at)) {
mergedPRs.push(mapPullRequest(pr, 'merged'))
}
const firstPR = prs[0]
if (
firstPR === undefined ||
(firstPR.merged_at && fromDate.isAfter(moment(firstPR.merged_at))) ||
mergedPRs.length >= maxPullRequests
) {
if (mergedPRs.length >= maxPullRequests) {
core.warning(`⚠️ Reached 'maxPullRequests' count ${maxPullRequests}`)
}
// bail out early to not keep iterating on PRs super old
return sortPrs(mergedPRs)
}
}
return sortPrs(mergedPRs)
}
async getOpen(owner: string, repo: string, maxPullRequests: number): Promise<PullRequestInfo[]> {
const openPrs: PullRequestInfo[] = []
const options = this.octokit.pulls.list.endpoint.merge({
owner,
repo,
state: 'open',
sort: 'created',
per_page: '100',
direction: 'desc'
})
for await (const response of this.octokit.paginate.iterator(options)) {
const prs: PullsListData = response.data as PullsListData
for (const pr of prs) {
openPrs.push(mapPullRequest(pr, 'open'))
}
const firstPR = prs[0]
if (firstPR === undefined || openPrs.length >= maxPullRequests) {
if (openPrs.length >= maxPullRequests) {
core.warning(`⚠️ Reached 'maxPullRequests' count ${maxPullRequests}`)
}
// bail out early to not keep iterating on PRs super old
return sortPrs(openPrs)
}
}
return sortPrs(openPrs)
}
async getReviews(owner: string, repo: string, pr: PullRequestInfo): Promise<void> {
const options = this.octokit.pulls.listReviews.endpoint.merge({
owner,
repo,
pull_number: pr.number,
sort: 'created',
direction: 'desc'
})
const prReviews: CommentInfo[] = []
for await (const response of this.octokit.paginate.iterator(options)) {
const comments: PullReviewsData = response.data as PullReviewsData
for (const comment of comments) {
prReviews.push(mapComment(comment))
}
}
pr.reviews = prReviews
}
async getMergedPullRequests(options: Options): Promise<[DiffInfo, PullRequestInfo[]]> {
const {owner, repo, includeOpen, fetchReviewers, fetchReviews, configuration} = options
const diffInfo = await this.commits.getCommitHistory(options)
const commits = diffInfo.commitInfo
if (commits.length === 0) {
return [diffInfo, []]
}
const firstCommit = commits[0]
const lastCommit = commits[commits.length - 1]
let fromDate = firstCommit.date
const toDate = lastCommit.date
const maxDays = configuration.max_back_track_time_days
const maxFromDate = toDate.clone().subtract(maxDays, 'days')
if (maxFromDate.isAfter(fromDate)) {
core.info(`⚠️ Adjusted 'fromDate' to go max ${maxDays} back`)
fromDate = maxFromDate
}
core.info(`️ Fetching PRs between dates ${fromDate.toISOString()} to ${toDate.toISOString()} for ${owner}/${repo}`)
const pullRequests = await this.getBetweenDates(owner, repo, fromDate, toDate, configuration.max_pull_requests)
core.info(`️ Retrieved ${pullRequests.length} PRs for ${owner}/${repo} in date range from API`)
const prCommits = filterCommits(commits, configuration.exclude_merge_branches)
core.info(`️ Retrieved ${prCommits.length} release commits for ${owner}/${repo}`)
// create array of commits for this release
const releaseCommitHashes = prCommits.map(commmit => {
return commmit.sha
})
// filter out pull requests not associated with this release
const mergedPullRequests = pullRequests.filter(pr => {
return releaseCommitHashes.includes(pr.mergeCommitSha)
})
core.info(`️ Retrieved ${mergedPullRequests.length} merged PRs for ${owner}/${repo}`)
let allPullRequests = mergedPullRequests
if (includeOpen) {
// retrieve all open pull requests
const openPullRequests = await this.getOpen(owner, repo, configuration.max_pull_requests)
core.info(`️ Retrieved ${openPullRequests.length} open PRs for ${owner}/${repo}`)
// all pull requests
allPullRequests = allPullRequests.concat(openPullRequests)
core.info(`️ Retrieved ${allPullRequests.length} total PRs for ${owner}/${repo}`)
}
// retrieve base branches we allow
const baseBranches = configuration.base_branches
const baseBranchPatterns = baseBranches.map(baseBranch => {
return new RegExp(baseBranch.replace('\\\\', '\\'), 'gu')
})
// return only prs if the baseBranch is matching the configuration
const finalPrs = allPullRequests.filter(pr => {
if (baseBranches.length !== 0) {
return baseBranchPatterns.some(pattern => {
return pr.baseBranch.match(pattern) !== null
})
}
return true
})
if (baseBranches.length !== 0) {
core.info(`️ Retrieved ${finalPrs.length} PRs for ${owner}/${repo} filtered by the 'base_branches' configuration.`)
}
// fetch reviewers only if enabled (requires an additional API request per PR)
if (fetchReviews || fetchReviewers) {
core.info(`️ Fetching reviews (or reviewers) was enabled`)
// update PR information with reviewers who approved
for (const pr of finalPrs) {
await this.getReviews(owner, repo, pr)
const reviews = pr.reviews
if (reviews && (reviews?.length || 0) > 0) {
core.info(`️ Retrieved ${reviews.length || 0} review(s) for PR ${owner}/${repo}/#${pr.number}`)
// backwards compatiblity
pr.approvedReviewers = reviews.filter(r => r.state === 'APPROVED').map(r => r.author)
} else {
core.debug(`No reviewer(s) for PR ${owner}/${repo}/#${pr.number}`)
}
}
} else {
core.debug(`️ Fetching reviews (or reviewers) was disabled`)
}
return [diffInfo, finalPrs]
}
}
function sortPrs(pullRequests: PullRequestInfo[]): PullRequestInfo[] {
return sortPullRequests(pullRequests, {
order: 'ASC',
on_property: 'mergedAt'
})
}
export function sortPullRequests(pullRequests: PullRequestInfo[], sort: Sort | string): PullRequestInfo[] {
let sortConfig: Sort
// legacy handling to support string sort config
if (typeof sort === 'string') {
let order: 'ASC' | 'DESC' = 'ASC'
if (sort.toUpperCase() === 'DESC') order = 'DESC'
sortConfig = {order, on_property: 'mergedAt'}
} else {
sortConfig = sort
}
if (sortConfig.order === 'ASC') {
pullRequests.sort((a, b) => {
return compare(a, b, sortConfig)
})
} else {
pullRequests.sort((b, a) => {
return compare(a, b, sortConfig)
})
}
return pullRequests
}
export function compare(a: PullRequestInfo, b: PullRequestInfo, sort: Sort): number {
if (sort.on_property === 'mergedAt') {
const aa = a.mergedAt || a.createdAt
const bb = b.mergedAt || b.createdAt
if (aa.isBefore(bb)) {
return -1
} else if (bb.isBefore(aa)) {
return 1
}
return 0
} else {
// only else for now `label`
return a.title.localeCompare(b.title)
}
}
/**
* Helper function to retrieve a property from the PullRequestInfo
*/
export function retrieveProperty(pr: PullRequestInfo, property: Property, useCase: string): string {
let value: string | number | Set<string> | string[] | undefined = pr[property]
if (value === undefined) {
core.warning(`⚠️ the provided property '${property}' for \`${useCase}\` is not valid. Fallback to 'body'`)
value = pr['body']
} else if (value instanceof Set) {
value = Array.from(value).join(',') // join into single string
} else if (Array.isArray(value)) {
value = value.join(',') // join into single string
} else {
value = value.toString()
}
return value
}
// helper function to add a special open label to prs not merged.
function attachSpeciaLabels(status: 'open' | 'merged', labels: string[]): string[] {
labels.push(`--rcba-${status}`)
return labels
}
const mapPullRequest = (pr: PullData | Unpacked<PullsListData>, status: 'open' | 'merged' = 'open'): PullRequestInfo => ({
number: pr.number,
title: pr.title,
htmlURL: pr.html_url,
baseBranch: pr.base.ref,
branch: pr.head.ref,
createdAt: moment(pr.created_at),
mergedAt: pr.merged_at ? moment(pr.merged_at) : undefined,
mergeCommitSha: pr.merge_commit_sha || '',
author: pr.user?.login || '',
repoName: pr.base.repo.full_name,
labels: attachSpeciaLabels(status, pr.labels?.map(lbl => lbl.name?.toLocaleLowerCase('en') || '') || []),
milestone: pr.milestone?.title || '',
body: pr.body || '',
assignees: pr.assignees?.map(asignee => asignee?.login || '') || [],
requestedReviewers: pr.requested_reviewers?.map(reviewer => reviewer?.login || '') || [],
approvedReviewers: [],
reviews: undefined,
status
})
const mapComment = (comment: Unpacked<PullReviewsData>): CommentInfo => ({
id: comment.id,
htmlURL: comment.html_url,
submittedAt: comment.submitted_at ? moment(comment.submitted_at) : undefined,
author: comment.user?.login || '',
body: comment.body,
state: comment.state
})
+105
View File
@@ -0,0 +1,105 @@
import * as core from '@actions/core'
import {Extractor, Property, Regex, Rule, Transformer} from './configuration'
import {PullRequestInfo, retrieveProperty} from './pullRequests'
/**
* Checks if any of the rules match the given PR
*/
export function matchesRules(rules: Rule[], pr: PullRequestInfo, exhaustive: Boolean): boolean {
const transformers: RegexTransformer[] = rules.map(rule => validateTransformer(rule)).filter(t => t !== null) as RegexTransformer[]
if (exhaustive) {
return transformers.every(transformer => {
return matches(pr, transformer, 'rule')
})
} else {
return transformers.some(transformer => {
return matches(pr, transformer, 'rule')
})
}
}
/**
* Checks if the configured property results in a positive `test` with the regex.
*/
function matches(pr: PullRequestInfo, extractor: RegexTransformer, extractor_usecase: string): boolean {
if (extractor.pattern == null) {
return false
}
if (extractor.onProperty !== undefined && extractor.onProperty.length === 1) {
const prop = extractor.onProperty[0]
const value = retrieveProperty(pr, prop, extractor_usecase)
const matched = extractor.pattern.test(value)
if (core.isDebug()) {
core.debug(` Pattern ${extractor.pattern} resulted in ${matched} for ${value} on PR ${pr.number} (usecase: ${extractor_usecase})`)
}
return matched
}
return false
}
export function validateTransformer(transformer?: Regex): RegexTransformer | null {
if (transformer === undefined) {
return null
}
try {
let target = undefined
if (transformer.hasOwnProperty('target')) {
target = (transformer as Transformer).target
}
let onProperty = undefined
let method = undefined
let onEmpty = undefined
if (transformer.hasOwnProperty('method')) {
method = (transformer as Extractor).method
onEmpty = (transformer as Extractor).on_empty
onProperty = (transformer as Extractor).on_property
} else if (transformer.hasOwnProperty('on_property')) {
onProperty = (transformer as Extractor).on_property
}
// legacy handling, transform single value input to array
if (!Array.isArray(onProperty)) {
if (onProperty !== undefined) {
onProperty = [onProperty]
}
}
return buildRegex(transformer, target, onProperty, method, onEmpty)
} catch (e) {
core.warning(`⚠️ Failed to validate transformer: ${transformer.pattern}`)
return null
}
}
/**
* Constructs the RegExp, providing the configured Regex and additional values
*/
export function buildRegex(
regex: Regex,
target: string | undefined,
onProperty?: Property[] | undefined,
method?: 'replace' | 'match' | undefined,
onEmpty?: string | undefined
): RegexTransformer | null {
try {
return {
pattern: new RegExp(regex.pattern.replace('\\\\', '\\'), regex.flags ?? 'gu'),
target: target || '',
onProperty,
method,
onEmpty
}
} catch (e) {
core.warning(`⚠️ Bad replacer regex: ${regex.pattern}`)
return null
}
}
export interface RegexTransformer {
pattern: RegExp | null
target: string
onProperty?: Property[]
method?: 'replace' | 'match'
onEmpty?: string
}
+329
View File
@@ -0,0 +1,329 @@
import * as core from '@actions/core'
import * as github from '@actions/github'
import * as semver from 'semver'
import {Octokit, RestEndpointMethodTypes} from '@octokit/rest'
import {SemVer} from 'semver'
import {TagResolver} from './configuration'
import {createCommandManager} from './gitHelper'
import moment from 'moment'
import {RegexTransformer, validateTransformer} from './regexUtils'
export interface TagResult {
from: TagInfo | null
to: TagInfo | null
}
export interface TagInfo {
name: string
commit?: string
date?: moment.Moment
}
export interface SortableTagInfo extends TagInfo {
tmp: string
}
export class Tags {
constructor(private octokit: Octokit) {}
async getTags(owner: string, repo: string, maxTagsToFetch: number): Promise<TagInfo[]> {
const tagsInfo: TagInfo[] = []
const options = this.octokit.repos.listTags.endpoint.merge({
owner,
repo,
direction: 'desc',
per_page: 100
})
for await (const response of this.octokit.paginate.iterator(options)) {
type TagsListData = RestEndpointMethodTypes['repos']['listTags']['response']['data']
const tags: TagsListData = response.data as TagsListData
for (const tag of tags) {
tagsInfo.push({
name: tag.name,
commit: tag.commit.sha
})
}
// for performance only fetch newest maxTagsToFetch tags!!
if (tagsInfo.length >= maxTagsToFetch) {
break
}
}
core.info(`️ Found ${tagsInfo.length} (fetching max: ${maxTagsToFetch}) tags from the GitHub API for ${owner}/${repo}`)
return tagsInfo
}
async fillTagInformation(repositoryPath: string, owner: string, repo: string, tagInfo: TagInfo): Promise<TagInfo> {
const options = this.octokit.repos.getReleaseByTag.endpoint.merge({
owner,
repo,
tag: tagInfo.name
})
try {
const response = await this.octokit.request(options)
type ReleaseInformation = RestEndpointMethodTypes['repos']['getReleaseByTag']['response']['data']
const release: ReleaseInformation = response.data as ReleaseInformation
tagInfo.date = moment(release.created_at)
core.info(`️ Retrieved information about the release associated with ${tagInfo.name} from the GitHub API`)
} catch (error) {
core.info(`⚠️ No release information found for ${tagInfo.name}, trying to retrieve tag creation time as fallback.`)
const gitHelper = await createCommandManager(repositoryPath)
const creationTimeString = await gitHelper.tagCreation(tagInfo.name)
const creationTime = moment(creationTimeString)
if (creationTimeString !== null && creationTime.isValid()) {
tagInfo.date = creationTime
core.info(
`️ Resolved tag creation time (${creationTimeString}) from 'git for-each-ref --format="%(creatordate:rfc)" "refs/tags/${tagInfo.name}`
)
} else {
core.info(
`⚠️ Could not retrieve tag creation time via git cli 'git for-each-ref --format="%(creatordate:rfc)" "refs/tags/${tagInfo.name}'`
)
}
}
return tagInfo
}
async findPredecessorTag(
sortedTags: TagInfo[],
repositoryPath: string,
tag: string,
ignorePreReleases: boolean
): Promise<TagInfo | null> {
const tags = sortedTags
try {
const length = tags.length
if (tags.length > 1) {
for (let i = 0; i < length; i++) {
if (tags[i].name.toLocaleLowerCase('en') === tag.toLocaleLowerCase('en')) {
if (ignorePreReleases) {
core.info(`️ Enabled 'ignorePreReleases', searching for the closest release`)
for (let ii = i + 1; ii < length; ii++) {
if (!tags[ii].name.includes('-')) {
return tags[ii]
}
}
}
return tags[i + 1]
}
}
} else {
core.info(`️ Only one tag found for the given repository. Usually this is the case for the initial release.`)
// if not specified try to retrieve tag from git
const gitHelper = await createCommandManager(repositoryPath)
const initialCommit = await gitHelper.initialCommit()
core.info(`🔖 Resolved initial commit (${initialCommit}) from 'git rev-list --max-parents=0 HEAD'`)
return {name: initialCommit, commit: initialCommit}
}
return tags[0]
} catch (error) {
if (tags.length <= 0) {
core.warning(`⚠️ No tag found for the given repository`)
}
return null
}
}
async retrieveRange(
repositoryPath: string,
owner: string,
repo: string,
fromTag: string | null,
toTag: string | null,
ignorePreReleases: boolean,
maxTagsToFetch: number,
tagResolver: TagResolver
): Promise<TagResult> {
// filter out tags not matching the specified filter
const filteredTags = filterTags(
// retrieve the tags from the API
await this.getTags(owner, repo, maxTagsToFetch),
tagResolver
)
// check if a transformer was defined
const tagTransformer = validateTransformer(tagResolver.transformer)
let transformedTags: TagInfo[]
if (tagTransformer != null) {
core.debug(`️ Using configured tagTransformer`)
transformedTags = transformTags(filteredTags, tagTransformer)
} else {
transformedTags = filteredTags
}
let tags = sortTags(transformedTags, tagResolver)
if (tagTransformer != null) {
// restore the original name, after sorting
tags = filteredTags.map(function (tag) {
if (tag.hasOwnProperty('tmp')) {
return {name: (tag as SortableTagInfo).tmp, commit: tag.commit}
} else {
return tag
}
})
}
let resultToTag: TagInfo | null
let resultFromTag: TagInfo | null
// ensure to resolve the toTag if it was not provided
if (!toTag) {
// if not specified try to retrieve tag from github.context.ref
if (github.context.ref?.startsWith('refs/tags/') === true) {
toTag = github.context.ref.replace('refs/tags/', '')
core.info(`🔖 Resolved current tag (${toTag}) from the 'github.context.ref'`)
resultToTag = {
name: toTag,
commit: toTag
}
} else if (tags.length > 1) {
resultToTag = tags[0]
core.info(`🔖 Resolved current tag (${resultToTag.name}) from the tags git API`)
} else {
// if not specified try to retrieve tag from git
const gitHelper = await createCommandManager(repositoryPath)
const latestTag = await gitHelper.latestTag()
core.info(`🔖 Resolved current tag (${latestTag}) from 'git rev-list --tags --skip=0 --max-count=1'`)
resultToTag = {
name: latestTag,
commit: latestTag
}
}
} else {
resultToTag = {
name: toTag,
commit: toTag
}
}
// ensure toTag is specified
toTag = resultToTag.name
// resolve the fromTag if not defined
if (!fromTag) {
core.debug(`fromTag undefined, trying to resolve via API`)
resultFromTag = await this.findPredecessorTag(tags, repositoryPath, toTag, ignorePreReleases)
if (resultFromTag != null) {
core.info(`🔖 Resolved previous tag (${resultFromTag.name}) from the tags git API`)
}
} else {
resultFromTag = {
name: fromTag,
commit: fromTag
}
}
return {
from: resultFromTag,
to: resultToTag
}
}
}
/*
* Uses the provided filter (if available) to filter out any tags not currently relevant.
* https://github.com/mikepenz/release-changelog-builder-action/issues/566
*/
export function filterTags(tags: TagInfo[], tagResolver: TagResolver): TagInfo[] {
const filter = tagResolver.filter
if (filter !== undefined) {
const regex = new RegExp(filter.pattern.replace('\\\\', '\\'), filter.flags ?? 'gu')
const filteredTags = tags.filter(tag => tag.name.match(regex) !== null)
core.debug(`️ Filtered tags count: ${filteredTags.length}, original count: ${tags.length}`)
return filteredTags
} else {
return tags
}
}
/**
* Helper function to transform the tag name given the transformer
*/
function transformTags(tags: TagInfo[], transformer: RegexTransformer): TagInfo[] {
return tags.map(function (tag) {
if (transformer.pattern) {
const transformedName = tag.name.replace(transformer.pattern, transformer.target)
core.debug(`️ Transformed ${tag.name} to ${transformedName}`)
return {
tmp: tag.name, // remember the original name
name: transformedName,
commit: tag.commit
}
} else {
return tag
}
})
}
/*
Sorts an array of tags as shown below:
2020.4.0
2020.4.0-rc02
2020.3.2
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
*/
export function sortTags(tags: TagInfo[], tagResolver: TagResolver): TagInfo[] {
if (tagResolver.method === 'sort') {
return stringSorting(tags)
} else {
return semVerSorting(tags)
}
}
function semVerSorting(tags: TagInfo[]): TagInfo[] {
// filter out tags which do not follow semver
const validatedTags = tags.filter(tag => {
const isValid =
semver.valid(tag.name, {
loose: true
}) !== null
if (!isValid) {
core.debug(`⚠️ dropped tag ${tag.name} because it is not a valid semver tag`)
}
return isValid
})
// sort using semver
validatedTags.sort((b, a) => {
return new SemVer(a.name, {
includePrerelease: true,
loose: true
}).compare(b.name)
})
return validatedTags
}
function stringSorting(tags: TagInfo[]): TagInfo[] {
return tags.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])
}
}
})
}
+49
View File
@@ -0,0 +1,49 @@
import * as core from '@actions/core'
import * as fs from 'fs'
/**
* Will automatically either report the message to the log, or mark the action as failed. Additionally defining the output failed, allowing it to be read in by other actions
*/
export function failOrError(message: string | Error, failOnError: boolean): void {
// if we report any failure, consider the action to have failed, may not make the build fail
core.setOutput('failed', true)
if (failOnError) {
core.setFailed(message)
} else {
core.error(message)
}
}
/**
* Checks if a given directory exists
*/
export function directoryExistsSync(inputPath: string, required?: boolean): boolean {
if (!inputPath) {
throw new Error("Arg 'path' must not be empty")
}
let stats: fs.Stats
try {
stats = fs.statSync(inputPath)
} catch (error: any /* eslint-disable-line @typescript-eslint/no-explicit-any */) {
if (error.code === 'ENOENT') {
if (!required) {
return false
}
throw new Error(`Directory '${inputPath}' does not exist`)
}
throw new Error(`Encountered an error when checking whether path '${inputPath}' exists: ${error.message}`)
}
if (stats.isDirectory()) {
return true
} else if (!required) {
return false
}
throw new Error(`Directory '${inputPath}' does not exist`)
}
export type Unpacked<T> = T extends (infer U)[] ? U : T
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
"outDir": "./lib", /* Redirect output structure to the directory. */
"rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
"lib": [ "ES2021.String" ], /* Enable custom `ES2021.String` extension in typescript for `replaceAll` */
"declaration": true
},
"exclude": ["node_modules", "**/*.test.ts", "lib"]
}