refactor: Extract github-related calls
This commit is contained in:
+22
-2
@@ -3,13 +3,31 @@ import * as github from '@actions/github'
|
||||
import {mergeConfiguration, parseConfiguration, resolveConfiguration, retrieveRepositoryPath, writeOutput} from './utils'
|
||||
import {ReleaseNotesBuilder} from './releaseNotesBuilder'
|
||||
import {Configuration} from './configuration'
|
||||
import {GithubRepository} from "./repositories/GithubRepository";
|
||||
import {GiteaRepository} from "./repositories/GiteaRepository";
|
||||
|
||||
async function run(): Promise<void> {
|
||||
|
||||
const supportedPlatform = {
|
||||
github: GithubRepository,
|
||||
gitea: GiteaRepository,
|
||||
};
|
||||
function isSupportedPlatform(type: string): type is keyof typeof supportedPlatform {
|
||||
return type in supportedPlatform;
|
||||
}
|
||||
core.setOutput('failed', false) // mark the action not failed by default
|
||||
|
||||
core.startGroup(`📘 Reading input values`)
|
||||
try {
|
||||
// read in path specification, resolve github workspace, and repo path
|
||||
const platform = core.getInput('platform') || "github"
|
||||
if(!isSupportedPlatform(platform)){
|
||||
core.setFailed(`The ${platform} platform is not supported. `)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
|
||||
const inputPath = core.getInput('path')
|
||||
const repositoryPath = retrieveRepositoryPath(inputPath)
|
||||
|
||||
@@ -40,7 +58,7 @@ async function run(): Promise<void> {
|
||||
|
||||
// read in repository inputs
|
||||
const baseUrl = core.getInput('baseUrl')
|
||||
const token = core.getInput('token')
|
||||
const token = core.getInput('token') || process.env.GITHUB_TOKEN || ""
|
||||
const owner = core.getInput('owner') || github.context.repo.owner
|
||||
const repo = core.getInput('repo') || github.context.repo.repo
|
||||
// read in from, to tag inputs
|
||||
@@ -59,9 +77,11 @@ async function run(): Promise<void> {
|
||||
const exportOnly = core.getInput('exportOnly') === 'true'
|
||||
const cache = core.getInput('cache')
|
||||
|
||||
|
||||
const repositoryUtils = new supportedPlatform[platform](token,baseUrl);
|
||||
const result = await new ReleaseNotesBuilder(
|
||||
baseUrl,
|
||||
token,
|
||||
repositoryUtils,
|
||||
repositoryPath,
|
||||
owner,
|
||||
repo,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../pr-collector/src/
|
||||
@@ -0,0 +1,155 @@
|
||||
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'
|
||||
import {BaseRepository} from "../repositories/BaseRepository";
|
||||
|
||||
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
|
||||
authorDate: moment.Moment
|
||||
committer: string
|
||||
commitDate: moment.Moment
|
||||
}
|
||||
|
||||
export class Commits {
|
||||
constructor(private repositoryUtils: BaseRepository) {
|
||||
}
|
||||
|
||||
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> {
|
||||
return this.repositoryUtils.getDiffRemote(owner, repo, base, head)
|
||||
}
|
||||
|
||||
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.commitDate.isBefore(b.commitDate)) {
|
||||
return -1
|
||||
} else if (b.commitDate.isBefore(a.commitDate)) {
|
||||
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.repositoryUtils)
|
||||
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.commitDate,
|
||||
mergedAt: commit.commitDate,
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import * as core from '@actions/core'
|
||||
import {PullConfiguration} from './types'
|
||||
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'
|
||||
import {BaseRepository} from "../repositories/BaseRepository";
|
||||
|
||||
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
|
||||
fetchViaCommits: boolean // defines if PRs are fetched via the commits identified. This will do 1 API request per commit -> Best for scenarios with squash merges | Or shorter from-to diffs (< 10 commits) | Also effective for shorters diffs for very old PRs
|
||||
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: PullConfiguration // the configuration as defined in `configuration.ts`
|
||||
}
|
||||
|
||||
export interface Data {
|
||||
diffInfo: DiffInfo
|
||||
mergedPullRequests: PullRequestInfo[]
|
||||
fromTag: TagInfo
|
||||
toTag: TagInfo
|
||||
}
|
||||
|
||||
export class PullRequestCollector {
|
||||
constructor(
|
||||
private baseUrl: string | null,
|
||||
private repositoryUtils: BaseRepository ,
|
||||
private repositoryPath: string,
|
||||
private owner: string,
|
||||
private repo: string,
|
||||
private fromTag: string | null,
|
||||
private toTag: string | null,
|
||||
private includeOpen = false,
|
||||
private failOnError: boolean,
|
||||
private ignorePreReleases: boolean,
|
||||
private fetchViaCommits = false,
|
||||
private fetchReviewers = false,
|
||||
private fetchReleaseInformation = false,
|
||||
private fetchReviews = false,
|
||||
private commitMode = false,
|
||||
private configuration: PullConfiguration,
|
||||
) {}
|
||||
|
||||
async build(): Promise<Data | null> {
|
||||
// check proxy setup for GHES environments
|
||||
|
||||
// ensure proper from <-> to tag range
|
||||
core.startGroup(`🔖 Resolve tags`)
|
||||
const tagsApi = new Tags(this.repositoryUtils)
|
||||
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.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.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()
|
||||
|
||||
return await pullData( this.repositoryUtils, {
|
||||
owner: this.owner,
|
||||
repo: this.repo,
|
||||
fromTag: previousTag,
|
||||
toTag: thisTag,
|
||||
includeOpen: this.includeOpen,
|
||||
failOnError: this.failOnError,
|
||||
fetchViaCommits: this.fetchViaCommits,
|
||||
fetchReviewers: this.fetchReviewers,
|
||||
fetchReleaseInformation: this.fetchReleaseInformation,
|
||||
fetchReviews: this.fetchReviews,
|
||||
commitMode: this.commitMode,
|
||||
configuration: this.configuration
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function pullData( repositoryUtils: BaseRepository , options: Options): Promise<Data | null> {
|
||||
let mergedPullRequests: PullRequestInfo[]
|
||||
let diffInfo: DiffInfo
|
||||
|
||||
const commitsApi = new Commits(repositoryUtils)
|
||||
if (!options.commitMode) {
|
||||
core.startGroup(`🚀 Load pull requests`)
|
||||
const pullRequestsApi = new PullRequests(repositoryUtils, 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,
|
||||
fromTag: options.fromTag,
|
||||
toTag: options.toTag
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import * as core from '@actions/core'
|
||||
import {Octokit, RestEndpointMethodTypes} from '@octokit/rest'
|
||||
import {Unpacked} from './utils'
|
||||
import moment from 'moment'
|
||||
import {Property, Sort} from './types'
|
||||
import {Commits, DiffInfo, filterCommits} from './commits'
|
||||
import {Options} from './prCollector'
|
||||
import {BaseRepository} from "../repositories/BaseRepository";
|
||||
|
||||
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_PULL_REQUEST_INFO: PullRequestInfo = {
|
||||
number: 0,
|
||||
title: '',
|
||||
htmlURL: '',
|
||||
baseBranch: '',
|
||||
mergedAt: undefined,
|
||||
createdAt: moment(),
|
||||
mergeCommitSha: '',
|
||||
author: '',
|
||||
repoName: '',
|
||||
labels: [],
|
||||
milestone: '',
|
||||
body: '',
|
||||
assignees: [],
|
||||
requestedReviewers: [],
|
||||
approvedReviewers: [],
|
||||
status: 'open'
|
||||
}
|
||||
|
||||
export const EMPTY_COMMENT_INFO: CommentInfo = {
|
||||
id: 0,
|
||||
htmlURL: '',
|
||||
submittedAt: undefined,
|
||||
author: '',
|
||||
body: '',
|
||||
state: undefined
|
||||
}
|
||||
|
||||
export type PullData = RestEndpointMethodTypes['pulls']['get']['response']['data']
|
||||
|
||||
export type PullsListData = RestEndpointMethodTypes['pulls']['list']['response']['data']
|
||||
|
||||
export type PullReviewsData = RestEndpointMethodTypes['pulls']['listReviews']['response']['data']
|
||||
|
||||
export class PullRequests {
|
||||
constructor(
|
||||
private repositoryUtils: BaseRepository,
|
||||
private commits: Commits
|
||||
) {}
|
||||
|
||||
|
||||
async getForCommitHash(owner: string, repo: string, commit_sha: string, maxPullRequests: number): Promise<PullRequestInfo[]> {
|
||||
return sortPrs(await this.repositoryUtils.getForCommitHash(owner, repo, commit_sha, maxPullRequests))
|
||||
}
|
||||
|
||||
async getBetweenDates(owner: string, repo: string, fromDate: moment.Moment, toDate: moment.Moment, maxPullRequests: number): Promise<PullRequestInfo[]> {
|
||||
return sortPrs(await this.repositoryUtils.getBetweenDates(owner, repo,fromDate,toDate,maxPullRequests))
|
||||
}
|
||||
|
||||
async getOpen(owner: string, repo: string, maxPullRequests: number): Promise<PullRequestInfo[]> {
|
||||
return sortPrs(await this.repositoryUtils.getOpen(owner, repo,maxPullRequests))
|
||||
}
|
||||
|
||||
async getReviews(owner: string, repo: string, pr: PullRequestInfo): Promise<void> {
|
||||
await this.repositoryUtils.getReviews(owner, repo, pr)
|
||||
}
|
||||
|
||||
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 = moment.min(firstCommit.authorDate, firstCommit.commitDate) // get the lower date (e.g. if commits are modified)
|
||||
const toDate = moment.max(lastCommit.authorDate, lastCommit.commitDate) // ensure we get the higher date (e.g. in case of rebases)
|
||||
|
||||
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 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(commit => {
|
||||
return commit.sha
|
||||
})
|
||||
|
||||
let pullRequests: PullRequestInfo[]
|
||||
if (options.fetchViaCommits) {
|
||||
// fetch PRs based on commits instead (will get associated PRs per commit found)
|
||||
const prsForReleaseCommits: Map<number, PullRequestInfo> = new Map()
|
||||
for (const commit of prCommits) {
|
||||
const result = await this.getForCommitHash(owner, repo, commit.sha, configuration.max_pull_requests)
|
||||
for (const pr of result) {
|
||||
prsForReleaseCommits.set(pr.number, pr)
|
||||
}
|
||||
}
|
||||
const dedupedPrsForReleaseCommits = Array.from(prsForReleaseCommits.values())
|
||||
if (!includeOpen) {
|
||||
pullRequests = dedupedPrsForReleaseCommits.filter(pr => pr.status !== 'open')
|
||||
core.info(`ℹ️ Retrieved ${pullRequests.length} PRs for ${owner}/${repo} based on the release commit hashes`)
|
||||
} else {
|
||||
pullRequests = dedupedPrsForReleaseCommits
|
||||
core.info(`ℹ️ Retrieved ${pullRequests.length} PRs for ${owner}/${repo} based on the release commit hashes (including open)`)
|
||||
}
|
||||
} else {
|
||||
// fetch PRs based on the date range identified
|
||||
const pullRequestsBetweenDate = await this.getBetweenDates(owner, repo, fromDate, toDate, configuration.max_pull_requests)
|
||||
core.info(`ℹ️ Retrieved ${pullRequestsBetweenDate.length} PRs for ${owner}/${repo} in date range from API`)
|
||||
|
||||
// filter out pull requests not associated with this release
|
||||
const mergedPullRequests = pullRequestsBetweenDate.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}`)
|
||||
}
|
||||
pullRequests = allPullRequests
|
||||
}
|
||||
|
||||
// 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 = pullRequests.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]
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchedEnough(pullRequests: PullsListData, fromDate: moment.Moment): boolean {
|
||||
for (let i = 0; i < Math.min(pullRequests.length, 3); i++) {
|
||||
const firstPR = pullRequests[i]
|
||||
if (!firstPR.merged_at) {
|
||||
continue // no merged_at timestamp -> look for the next
|
||||
} else if (fromDate.isAfter(moment(firstPR.merged_at))) {
|
||||
return true
|
||||
} else {
|
||||
break // not enough PRs yet, go further
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export 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
|
||||
})
|
||||
|
||||
export 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
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as core from '@actions/core'
|
||||
import {Extractor, Property, Regex, RegexTransformer, Transformer} from './types'
|
||||
|
||||
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 regex: ${regex.pattern} (${e})`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as github from '@actions/github'
|
||||
import * as semver from 'semver'
|
||||
import {SemVer} from 'semver'
|
||||
import {RegexTransformer, TagResolver, Transformer} from './types'
|
||||
import {createCommandManager} from './gitHelper'
|
||||
import moment from 'moment'
|
||||
import {validateTransformer} from './regexUtils'
|
||||
import {BaseRepository} from "../repositories/BaseRepository";
|
||||
|
||||
export interface TagResult {
|
||||
from: TagInfo | null
|
||||
to: TagInfo | null
|
||||
}
|
||||
|
||||
export interface TagInfo {
|
||||
name: string
|
||||
commit?: string
|
||||
preRelease?: boolean
|
||||
date?: moment.Moment
|
||||
}
|
||||
|
||||
export interface SortableTagInfo extends TagInfo {
|
||||
tmp: string
|
||||
}
|
||||
|
||||
export class Tags {
|
||||
constructor(private repositoryUtils: BaseRepository) {}
|
||||
|
||||
async getTags(owner: string, repo: string, maxTagsToFetch: number): Promise<TagInfo[]> {
|
||||
return this.repositoryUtils.getTags(owner,repo,maxTagsToFetch)
|
||||
}
|
||||
|
||||
async fillTagInformation(repositoryPath: string, owner: string, repo: string, tagInfo: TagInfo): Promise<TagInfo> {
|
||||
return this.repositoryUtils.fillTagInformation(repositoryPath,owner,repo,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].preRelease) {
|
||||
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> {
|
||||
let tags: TagInfo[] = []
|
||||
|
||||
if (!toTag || !fromTag) {
|
||||
// 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, legacy handling, transform single value input to array
|
||||
let tagTransfomers: Transformer[] | undefined = undefined
|
||||
if (tagResolver.transformer !== undefined) {
|
||||
if (!Array.isArray(tagResolver.transformer)) {
|
||||
tagTransfomers = [tagResolver.transformer]
|
||||
} else {
|
||||
tagTransfomers = tagResolver.transformer
|
||||
}
|
||||
}
|
||||
|
||||
let transformed = false
|
||||
let transformedTags: TagInfo[] = filteredTags
|
||||
if (tagTransfomers !== undefined && tagTransfomers.length > 0) {
|
||||
for (const transformer of tagTransfomers) {
|
||||
const tagTransformer = validateTransformer(transformer)
|
||||
if (tagTransformer != null) {
|
||||
core.debug(`ℹ️ Using configured tagTransformer (${transformer.pattern})`)
|
||||
transformedTags = transformTags(transformedTags, tagTransformer)
|
||||
transformed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sort tags, apply additional information (e.g. if tag is a pre release)
|
||||
tags = prepareAndSortTags(transformedTags, tagResolver)
|
||||
|
||||
if (transformed) {
|
||||
// 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 prepareAndSortTags(tags: TagInfo[], tagResolver: TagResolver): TagInfo[] {
|
||||
if (tagResolver.method === 'sort') {
|
||||
return stringTags(tags)
|
||||
} else {
|
||||
// semver is default
|
||||
return semVerTags(tags)
|
||||
}
|
||||
}
|
||||
|
||||
function semVerTags(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`)
|
||||
} else {
|
||||
tag.preRelease =
|
||||
semver.prerelease(tag.name, {
|
||||
loose: true
|
||||
}) != null
|
||||
}
|
||||
return isValid
|
||||
})
|
||||
|
||||
// sort using semver
|
||||
validatedTags.sort((b, a) => {
|
||||
return new SemVer(a.name, {
|
||||
includePrerelease: true,
|
||||
loose: true
|
||||
}).compare(b.name)
|
||||
})
|
||||
return validatedTags
|
||||
}
|
||||
|
||||
function stringTags(tags: TagInfo[]): TagInfo[] {
|
||||
for (const tag of tags) {
|
||||
tag.preRelease = tag.name.includes('-')
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export interface PullConfiguration {
|
||||
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 | 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)
|
||||
}
|
||||
|
||||
export interface RegexTransformer {
|
||||
pattern: RegExp | null
|
||||
target: string
|
||||
onProperty?: Property[]
|
||||
method?: 'replace' | 'match'
|
||||
onEmpty?: string
|
||||
}
|
||||
@@ -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
|
||||
@@ -8,6 +8,7 @@ import {TagInfo} from './pr-collector/tags'
|
||||
import {DiffInfo} from './pr-collector/commits'
|
||||
import {PullRequestInfo} from './pr-collector/pullRequests'
|
||||
import * as fs from 'fs'
|
||||
import {BaseRepository} from "./repositories/BaseRepository";
|
||||
|
||||
export interface ReleaseNotesOptions {
|
||||
owner: string // the owner of the repository
|
||||
@@ -32,7 +33,7 @@ export interface Data {
|
||||
export class ReleaseNotesBuilder {
|
||||
constructor(
|
||||
private baseUrl: string | null,
|
||||
private token: string | null,
|
||||
private repositoryUtils: BaseRepository,
|
||||
private repositoryPath: string,
|
||||
private owner: string | null,
|
||||
private repo: string | null,
|
||||
@@ -78,7 +79,7 @@ export class ReleaseNotesBuilder {
|
||||
|
||||
const prData = await new PullRequestCollector(
|
||||
this.baseUrl,
|
||||
this.token,
|
||||
this.repositoryUtils,
|
||||
this.repositoryPath,
|
||||
this.owner,
|
||||
this.repo,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import {TagInfo} from "../pr-collector/tags";
|
||||
import {DiffInfo} from "../pr-collector/commits";
|
||||
import {Options} from "../pr-collector/prCollector";
|
||||
import moment from "moment/moment";
|
||||
import {PullRequestInfo} from "../pr-collector/pullRequests";
|
||||
|
||||
export abstract class BaseRepository {
|
||||
proxy?: string;
|
||||
noProxyArray: string[]
|
||||
|
||||
// Define an abstract getter for the default URL
|
||||
abstract get defaultUrl(): string;
|
||||
|
||||
protected constructor(protected token: string, protected url?: string) {
|
||||
this.proxy = process.env.https_proxy || process.env.HTTPS_PROXY
|
||||
const noProxy = process.env.no_proxy || process.env.NO_PROXY
|
||||
this.noProxyArray = []
|
||||
if (noProxy) {
|
||||
this.noProxyArray = noProxy.split(',')
|
||||
}
|
||||
}
|
||||
|
||||
abstract getTags(owner: string, repo: string, maxTagsToFetch: number): Promise<TagInfo[]>
|
||||
|
||||
abstract fillTagInformation(repositoryPath: string, owner: string, repo: string, tagInfo: TagInfo): Promise<TagInfo>
|
||||
|
||||
abstract getDiffRemote(owner: string, repo: string, base: string, head: string): Promise<DiffInfo>
|
||||
|
||||
|
||||
abstract getForCommitHash(owner: string, repo: string, commit_sha: string, maxPullRequests: number): Promise<PullRequestInfo[]>
|
||||
|
||||
abstract getBetweenDates(owner: string, repo: string, fromDate: moment.Moment, toDate: moment.Moment, maxPullRequests: number): Promise<PullRequestInfo[]>
|
||||
|
||||
abstract getOpen(owner: string, repo: string, maxPullRequests: number): Promise<PullRequestInfo[]>
|
||||
|
||||
abstract getReviews(owner: string, repo: string, pr: PullRequestInfo): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {BaseRepository} from "./BaseRepository";
|
||||
import {TagInfo} from "../pr-collector/tags";
|
||||
import {PullRequestInfo} from "../pr-collector/pullRequests";
|
||||
import {DiffInfo} from "../pr-collector/commits";
|
||||
|
||||
export class GiteaRepository extends BaseRepository{
|
||||
get defaultUrl(): string {
|
||||
return "https://gitea.com/api/v1";
|
||||
}
|
||||
constructor(token: string, url?: string) {
|
||||
super(token, url);
|
||||
this.url = url || this.defaultUrl
|
||||
}
|
||||
|
||||
fillTagInformation(repositoryPath: string, owner: string, repo: string, tagInfo: TagInfo): Promise<TagInfo> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
getBetweenDates(owner: string, repo: string, fromDate: moment.Moment, toDate: moment.Moment, maxPullRequests: number): Promise<PullRequestInfo[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
getDiffRemote(owner: string, repo: string, base: string, head: string): Promise<DiffInfo> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
getForCommitHash(owner: string, repo: string, commit_sha: string, maxPullRequests: number): Promise<PullRequestInfo[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
getOpen(owner: string, repo: string, maxPullRequests: number): Promise<PullRequestInfo[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
getReviews(owner: string, repo: string, pr: PullRequestInfo): Promise<void> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
getTags(owner: string, repo: string, maxTagsToFetch: number): Promise<TagInfo[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import {BaseRepository} from "./BaseRepository";
|
||||
import {Octokit, RestEndpointMethodTypes} from "@octokit/rest";
|
||||
import {HttpsProxyAgent} from "https-proxy-agent";
|
||||
import * as core from "@actions/core";
|
||||
import {TagInfo} from "../pr-collector/tags";
|
||||
import moment from "moment/moment";
|
||||
import {createCommandManager} from "../pr-collector/gitHelper";
|
||||
import {DiffInfo} from "../pr-collector/commits";
|
||||
import {
|
||||
CommentInfo,
|
||||
fetchedEnough,
|
||||
mapComment,
|
||||
mapPullRequest,
|
||||
PullRequestInfo,
|
||||
PullReviewsData, PullsListData
|
||||
} from "../pr-collector/pullRequests";
|
||||
|
||||
export class GithubRepository extends BaseRepository {
|
||||
|
||||
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,
|
||||
author: commit.author?.login || '',
|
||||
authorDate: moment(commit.commit.author?.date),
|
||||
committer: commit.committer?.login || '',
|
||||
commitDate: moment(commit.commit.committer?.date),
|
||||
prNumber: undefined
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async getForCommitHash(owner: string, repo: string, commit_sha: string, maxPullRequests: number): Promise<PullRequestInfo[]> {
|
||||
const mergedPRs: PullRequestInfo[] = []
|
||||
const options = this.octokit.repos.listPullRequestsAssociatedWithCommit.endpoint.merge({
|
||||
owner,
|
||||
repo,
|
||||
commit_sha,
|
||||
per_page: `${Math.min(10, 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) {
|
||||
mergedPRs.push(mapPullRequest(pr, pr.merged_at ? 'merged' : 'open'))
|
||||
}
|
||||
}
|
||||
return mergedPRs
|
||||
}
|
||||
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'))
|
||||
}
|
||||
if (mergedPRs.length >= maxPullRequests) {
|
||||
core.warning(`⚠️ Reached 'maxPullRequests' count ${maxPullRequests} (1)`)
|
||||
break // bail out early to not keep iterating forever
|
||||
} else if (prs.length > 0) {
|
||||
if (fetchedEnough(prs, fromDate)) {
|
||||
return mergedPRs // bail out early to not keep iterating on PRs super old
|
||||
}
|
||||
} else {
|
||||
core.debug(`⚠️ No more PRs retrieved from API. Fetched so far: ${mergedPRs.length}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
return 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} (2)`)
|
||||
}
|
||||
break // bail out early to not keep iterating forever
|
||||
}
|
||||
}
|
||||
return 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
|
||||
}
|
||||
get defaultUrl(): string {
|
||||
return "https://api.example.com";
|
||||
}
|
||||
private octokit:Octokit
|
||||
constructor(token: string, url?: string) {
|
||||
super(token, url);
|
||||
this.url = url || this.defaultUrl
|
||||
|
||||
// load octokit instance
|
||||
this.octokit = new Octokit({
|
||||
auth: `token ${this.token}`,
|
||||
baseUrl: this.url
|
||||
})
|
||||
if (this.proxy) {
|
||||
const agent = new HttpsProxyAgent(this.proxy)
|
||||
this.octokit.hook.before('request', options => {
|
||||
if (this.noProxyArray.includes(options.request.hostname)) {
|
||||
return
|
||||
}
|
||||
options.request.agent = agent
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user