diff --git a/.gitignore b/.gitignore index 020bdae..5149657 100644 --- a/.gitignore +++ b/.gitignore @@ -99,4 +99,4 @@ __tests__/runner/* lib/**/* lib -pr-collector/dist \ No newline at end of file +src/pr-collector/dist \ No newline at end of file diff --git a/pr-collector/src/commits.ts b/pr-collector/src/commits.ts deleted file mode 100755 index 59d8eb6..0000000 --- a/pr-collector/src/commits.ts +++ /dev/null @@ -1,208 +0,0 @@ -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' - -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 octokit: Octokit) {} - - async getDiff(owner: string, repo: string, base: string, head: string): Promise { - 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 { - 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 - })) - } - } - - 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 { - const {owner, repo, fromTag, toTag, failOnError} = options - core.info(`ℹ️ Comparing ${owner}/${repo} - '${fromTag.name}...${toTag.name}'`) - - const commitsApi = new Commits(this.octokit) - 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 -} diff --git a/pr-collector/src/types.ts b/pr-collector/src/types.ts deleted file mode 100644 index dd17cfa..0000000 --- a/pr-collector/src/types.ts +++ /dev/null @@ -1,63 +0,0 @@ -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 -} diff --git a/src/main.ts b/src/main.ts index 2e0a5a2..d013fa4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { + + 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 { // 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 { 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, diff --git a/src/pr-collector b/src/pr-collector deleted file mode 120000 index 9d4d54d..0000000 --- a/src/pr-collector +++ /dev/null @@ -1 +0,0 @@ -../pr-collector/src/ \ No newline at end of file diff --git a/src/pr-collector/commits.ts b/src/pr-collector/commits.ts new file mode 100644 index 0000000..ee2a1c0 --- /dev/null +++ b/src/pr-collector/commits.ts @@ -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 { + 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 { + 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 { + 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 +} diff --git a/pr-collector/src/gitHelper.ts b/src/pr-collector/gitHelper.ts similarity index 100% rename from pr-collector/src/gitHelper.ts rename to src/pr-collector/gitHelper.ts diff --git a/pr-collector/src/prCollector.ts b/src/pr-collector/prCollector.ts similarity index 82% rename from pr-collector/src/prCollector.ts rename to src/pr-collector/prCollector.ts index 02932ef..f81a61e 100644 --- a/pr-collector/src/prCollector.ts +++ b/src/pr-collector/prCollector.ts @@ -6,6 +6,7 @@ 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 @@ -32,7 +33,7 @@ export interface Data { export class PullRequestCollector { constructor( private baseUrl: string | null, - private token: string | null, + private repositoryUtils: BaseRepository , private repositoryPath: string, private owner: string, private repo: string, @@ -46,37 +47,15 @@ export class PullRequestCollector { private fetchReleaseInformation = false, private fetchReviews = false, private commitMode = false, - private configuration: PullConfiguration + private configuration: PullConfiguration, ) {} async build(): Promise { // check proxy setup for GHES environments - const proxy = process.env.https_proxy || process.env.HTTPS_PROXY - const noProxy = process.env.no_proxy || process.env.NO_PROXY - let noProxyArray: string[] = [] - if (noProxy) { - noProxyArray = noProxy.split(',') - } - - // load octokit instance - const octokit = new Octokit({ - auth: `token ${this.token || process.env.GITHUB_TOKEN}`, - baseUrl: `${this.baseUrl || 'https://api.github.com'}` - }) - - if (proxy) { - const agent = new HttpsProxyAgent(proxy) - octokit.hook.before('request', options => { - if (noProxyArray.includes(options.request.hostname)) { - return - } - options.request.agent = agent - }) - } // ensure proper from <-> to tag range core.startGroup(`🔖 Resolve tags`) - const tagsApi = new Tags(octokit) + const tagsApi = new Tags(this.repositoryUtils) const tagRange = await tagsApi.retrieveRange( this.repositoryPath, this.owner, @@ -114,7 +93,7 @@ export class PullRequestCollector { core.endGroup() - return await pullData(octokit, { + return await pullData( this.repositoryUtils, { owner: this.owner, repo: this.repo, fromTag: previousTag, @@ -131,14 +110,14 @@ export class PullRequestCollector { } } -export async function pullData(octokit: Octokit, options: Options): Promise { +export async function pullData( repositoryUtils: BaseRepository , options: Options): Promise { let mergedPullRequests: PullRequestInfo[] let diffInfo: DiffInfo - const commitsApi = new Commits(octokit) + const commitsApi = new Commits(repositoryUtils) if (!options.commitMode) { core.startGroup(`🚀 Load pull requests`) - const pullRequestsApi = new PullRequests(octokit, commitsApi) + const pullRequestsApi = new PullRequests(repositoryUtils, commitsApi) const [info, prs] = await pullRequestsApi.getMergedPullRequests(options) mergedPullRequests = prs diffInfo = info diff --git a/pr-collector/src/pullRequests.ts b/src/pr-collector/pullRequests.ts old mode 100755 new mode 100644 similarity index 71% rename from pr-collector/src/pullRequests.ts rename to src/pr-collector/pullRequests.ts index 517eb4b..844d51e --- a/pr-collector/src/pullRequests.ts +++ b/src/pr-collector/pullRequests.ts @@ -5,6 +5,7 @@ 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 @@ -64,142 +65,33 @@ export const EMPTY_COMMENT_INFO: CommentInfo = { state: undefined } -type PullData = RestEndpointMethodTypes['pulls']['get']['response']['data'] +export type PullData = RestEndpointMethodTypes['pulls']['get']['response']['data'] -type PullsListData = RestEndpointMethodTypes['pulls']['list']['response']['data'] +export type PullsListData = RestEndpointMethodTypes['pulls']['list']['response']['data'] -type PullReviewsData = RestEndpointMethodTypes['pulls']['listReviews']['response']['data'] +export type PullReviewsData = RestEndpointMethodTypes['pulls']['listReviews']['response']['data'] export class PullRequests { constructor( - private octokit: Octokit, + private repositoryUtils: BaseRepository, private commits: Commits ) {} - async getSingle(owner: string, repo: string, prNumber: number): Promise { - try { - const {data} = await this.octokit.pulls.get({ - owner, - repo, - pull_number: prNumber - }) - - return mapPullRequest(data) - } catch (e: any /* eslint-disable-line @typescript-eslint/no-explicit-any */) { - core.warning(`⚠️ Cannot find PR ${owner}/${repo}#${prNumber} - ${e.message}`) - return null - } - } async getForCommitHash(owner: string, repo: string, commit_sha: string, maxPullRequests: number): Promise { - 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 sortPrs(mergedPRs) + 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 { - 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 sortPrs(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 sortPrs(mergedPRs) + async getBetweenDates(owner: string, repo: string, fromDate: moment.Moment, toDate: moment.Moment, maxPullRequests: number): Promise { + return sortPrs(await this.repositoryUtils.getBetweenDates(owner, repo,fromDate,toDate,maxPullRequests)) } async getOpen(owner: string, repo: string, maxPullRequests: number): Promise { - 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 sortPrs(openPrs) + return sortPrs(await this.repositoryUtils.getOpen(owner, repo,maxPullRequests)) } async getReviews(owner: string, repo: string, pr: PullRequestInfo): Promise { - 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 + await this.repositoryUtils.getReviews(owner, repo, pr) } async getMergedPullRequests(options: Options): Promise<[DiffInfo, PullRequestInfo[]]> { @@ -323,7 +215,7 @@ export class PullRequests { } } -function fetchedEnough(pullRequests: PullsListData, fromDate: moment.Moment): boolean { +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) { @@ -408,7 +300,7 @@ function attachSpeciaLabels(status: 'open' | 'merged', labels: string[]): string return labels } -const mapPullRequest = (pr: PullData | Unpacked, status: 'open' | 'merged' = 'open'): PullRequestInfo => ({ +export const mapPullRequest = (pr: PullData | Unpacked, status: 'open' | 'merged' = 'open'): PullRequestInfo => ({ number: pr.number, title: pr.title, htmlURL: pr.html_url, @@ -429,7 +321,7 @@ const mapPullRequest = (pr: PullData | Unpacked, status: 'open' | status }) -const mapComment = (comment: Unpacked): CommentInfo => ({ +export const mapComment = (comment: Unpacked): CommentInfo => ({ id: comment.id, htmlURL: comment.html_url, submittedAt: comment.submitted_at ? moment(comment.submitted_at) : undefined, diff --git a/pr-collector/src/regexUtils.ts b/src/pr-collector/regexUtils.ts similarity index 100% rename from pr-collector/src/regexUtils.ts rename to src/pr-collector/regexUtils.ts diff --git a/pr-collector/src/tags.ts b/src/pr-collector/tags.ts old mode 100755 new mode 100644 similarity index 79% rename from pr-collector/src/tags.ts rename to src/pr-collector/tags.ts index ce4a416..305b5b2 --- a/pr-collector/src/tags.ts +++ b/src/pr-collector/tags.ts @@ -1,12 +1,12 @@ import * as core from '@actions/core' import * as github from '@actions/github' import * as semver from 'semver' -import {Octokit, RestEndpointMethodTypes} from '@octokit/rest' 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 @@ -25,69 +25,14 @@ export interface SortableTagInfo extends TagInfo { } export class Tags { - constructor(private octokit: Octokit) {} + constructor(private repositoryUtils: BaseRepository) {} async getTags(owner: string, repo: string, maxTagsToFetch: number): Promise { - 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 + return this.repositoryUtils.getTags(owner,repo,maxTagsToFetch) } async fillTagInformation(repositoryPath: string, owner: string, repo: string, tagInfo: TagInfo): Promise { - 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 + return this.repositoryUtils.fillTagInformation(repositoryPath,owner,repo,tagInfo) } async findPredecessorTag( diff --git a/src/pr-collector/types.ts b/src/pr-collector/types.ts new file mode 100644 index 0000000..6166942 --- /dev/null +++ b/src/pr-collector/types.ts @@ -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 +} \ No newline at end of file diff --git a/pr-collector/src/utils.ts b/src/pr-collector/utils.ts similarity index 100% rename from pr-collector/src/utils.ts rename to src/pr-collector/utils.ts diff --git a/src/releaseNotesBuilder.ts b/src/releaseNotesBuilder.ts index 1fc4f7f..808053c 100644 --- a/src/releaseNotesBuilder.ts +++ b/src/releaseNotesBuilder.ts @@ -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, diff --git a/src/repositories/BaseRepository.ts b/src/repositories/BaseRepository.ts new file mode 100644 index 0000000..820e3f0 --- /dev/null +++ b/src/repositories/BaseRepository.ts @@ -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 + + abstract fillTagInformation(repositoryPath: string, owner: string, repo: string, tagInfo: TagInfo): Promise + + abstract getDiffRemote(owner: string, repo: string, base: string, head: string): Promise + + + abstract getForCommitHash(owner: string, repo: string, commit_sha: string, maxPullRequests: number): Promise + + abstract getBetweenDates(owner: string, repo: string, fromDate: moment.Moment, toDate: moment.Moment, maxPullRequests: number): Promise + + abstract getOpen(owner: string, repo: string, maxPullRequests: number): Promise + + abstract getReviews(owner: string, repo: string, pr: PullRequestInfo): Promise +} \ No newline at end of file diff --git a/src/repositories/GiteaRepository.ts b/src/repositories/GiteaRepository.ts new file mode 100644 index 0000000..20fda33 --- /dev/null +++ b/src/repositories/GiteaRepository.ts @@ -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 { + return Promise.resolve(undefined); + } + + getBetweenDates(owner: string, repo: string, fromDate: moment.Moment, toDate: moment.Moment, maxPullRequests: number): Promise { + return Promise.resolve([]); + } + + getDiffRemote(owner: string, repo: string, base: string, head: string): Promise { + return Promise.resolve(undefined); + } + + getForCommitHash(owner: string, repo: string, commit_sha: string, maxPullRequests: number): Promise { + return Promise.resolve([]); + } + + getOpen(owner: string, repo: string, maxPullRequests: number): Promise { + return Promise.resolve([]); + } + + getReviews(owner: string, repo: string, pr: PullRequestInfo): Promise { + return Promise.resolve(undefined); + } + + getTags(owner: string, repo: string, maxTagsToFetch: number): Promise { + return Promise.resolve([]); + } + +} \ No newline at end of file diff --git a/src/repositories/GithubRepository.ts b/src/repositories/GithubRepository.ts new file mode 100644 index 0000000..9ce7deb --- /dev/null +++ b/src/repositories/GithubRepository.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + } + + +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 188cb06..17300d0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,5 +9,7 @@ "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ "lib": [ "ES2021.String" ] /* Enable custom `ES2021.String` extension in typescript for `replaceAll` */ }, - "exclude": ["node_modules", "**/*.test.ts", "pr-collector"] + "exclude": ["node_modules", "**/*.test.ts", + "src/pr-collector" + ] }