refactor: Extract github-related calls

This commit is contained in:
Ankio
2023-10-31 17:51:00 +08:00
parent e2753d2444
commit 0bb95b5a6f
18 changed files with 614 additions and 487 deletions
+22 -2
View File
@@ -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
View File
@@ -1 +0,0 @@
../pr-collector/src/
+155
View File
@@ -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
}
+74
View File
@@ -0,0 +1,74 @@
import * as exec from '@actions/exec'
import * as io from '@actions/io'
import {directoryExistsSync} from './utils'
export async function createCommandManager(workingDirectory: string): Promise<GitCommandManager> {
return await GitCommandManager.createCommandManager(workingDirectory)
}
class GitCommandManager {
private gitPath = ''
private workingDirectory = ''
// Private constructor; use createCommandManager()
private constructor() {}
getWorkingDirectory(): string {
return this.workingDirectory
}
async latestTag(): Promise<string> {
const revListOutput = await this.execGit(['rev-list', '--tags', '--skip=0', '--max-count=1'])
const output = await this.execGit(['describe', '--abbrev=0', '--tags', revListOutput.stdout.trim()])
return output.stdout.trim()
}
async initialCommit(): Promise<string> {
const revListOutput = await this.execGit(['rev-list', '--max-parents=0', 'HEAD'])
return revListOutput.stdout.trim()
}
async tagCreation(tagName: string): Promise<string> {
const creationDate = await this.execGit(['for-each-ref', '--format="%(creatordate:rfc)"', `refs/tags/${tagName}`])
return creationDate.stdout.trim().replace(/"/g, '')
}
static async createCommandManager(workingDirectory: string): Promise<GitCommandManager> {
const result = new GitCommandManager()
await result.initializeCommandManager(workingDirectory)
return result
}
private async execGit(args: string[], allowAllExitCodes = false, silent = false): Promise<GitOutput> {
directoryExistsSync(this.workingDirectory, true)
const result = new GitOutput()
const stdout: string[] = []
const options = {
cwd: this.workingDirectory,
silent,
ignoreReturnCode: allowAllExitCodes,
listeners: {
stdout: (data: Buffer) => {
stdout.push(data.toString())
}
}
}
result.exitCode = await exec.exec(`"${this.gitPath}"`, args, options)
result.stdout = stdout.join('')
return result
}
private async initializeCommandManager(workingDirectory: string): Promise<void> {
this.workingDirectory = workingDirectory
this.gitPath = await io.which('git', true)
}
}
class GitOutput {
stdout = ''
exitCode = 0
}
+139
View File
@@ -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
}
}
+331
View File
@@ -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
})
+60
View File
@@ -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
}
}
+302
View File
@@ -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])
}
}
})
}
+63
View File
@@ -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
}
+49
View File
@@ -0,0 +1,49 @@
import * as core from '@actions/core'
import * as fs from 'fs'
/**
* Will automatically either report the message to the log, or mark the action as failed. Additionally defining the output failed, allowing it to be read in by other actions
*/
export function failOrError(message: string | Error, failOnError: boolean): void {
// if we report any failure, consider the action to have failed, may not make the build fail
core.setOutput('failed', true)
if (failOnError) {
core.setFailed(message)
} else {
core.error(message)
}
}
/**
* Checks if a given directory exists
*/
export function directoryExistsSync(inputPath: string, required?: boolean): boolean {
if (!inputPath) {
throw new Error("Arg 'path' must not be empty")
}
let stats: fs.Stats
try {
stats = fs.statSync(inputPath)
} catch (error: any /* eslint-disable-line @typescript-eslint/no-explicit-any */) {
if (error.code === 'ENOENT') {
if (!required) {
return false
}
throw new Error(`Directory '${inputPath}' does not exist`)
}
throw new Error(`Encountered an error when checking whether path '${inputPath}' exists: ${error.message}`)
}
if (stats.isDirectory()) {
return true
} else if (!required) {
return false
}
throw new Error(`Directory '${inputPath}' does not exist`)
}
export type Unpacked<T> = T extends (infer U)[] ? U : T
+3 -2
View File
@@ -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,
+37
View File
@@ -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>
}
+43
View File
@@ -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([]);
}
}
+262
View File
@@ -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
}
}