- move pr collection into npm module
(publish without any furhter notes or docs) - refactor action to use npm dependency
This commit is contained in:
Executable
+365
@@ -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
|
||||
})
|
||||
Reference in New Issue
Block a user