- first collect all pull requests and export to environment - second take exported data and continue generation of changelogs (makes it cheaper, by only collecting PRs once)
366 lines
12 KiB
TypeScript
Executable File
366 lines
12 KiB
TypeScript
Executable File
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 {ReleaseNotesOptions} from './releaseNotesBuilder'
|
||
|
||
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: ReleaseNotesOptions): 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
|
||
})
|