- 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
+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
})