From 7aac2c235266c52677193c5a0865a6e333637b2c Mon Sep 17 00:00:00 2001 From: Daniil Samoylov Date: Mon, 12 Apr 2021 17:01:05 +1200 Subject: [PATCH] Add action implementation --- .github/workflows/test.yml | 18 ++++- src/fetchChangelogs.ts | 23 +++++++ src/getPrCommentBody.ts | 111 +++++++++++++++++++++++++++++++ src/getRenovateConfig.ts | 54 +++++++++++++++ src/getUpdatedDependencies.ts | 69 +++++++++++++++++++ src/githubActionsBunyanStream.ts | 35 ++++++++++ src/main.ts | 82 +++++++++++++++++++++-- src/types.d.ts | 17 +++++ src/upsertPrComment.ts | 35 ++++++++++ 9 files changed, 435 insertions(+), 9 deletions(-) create mode 100644 src/fetchChangelogs.ts create mode 100644 src/getPrCommentBody.ts create mode 100644 src/getRenovateConfig.ts create mode 100644 src/getUpdatedDependencies.ts create mode 100644 src/githubActionsBunyanStream.ts create mode 100644 src/types.d.ts create mode 100644 src/upsertPrComment.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ef07cd0..82790a3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,10 +15,24 @@ jobs: npm install - run: | npm run all - test: # make sure the action works on a clean machine without building + test: # make sure the action works on a clean machine without building, the action can only run on pull_requests runs-on: ubuntu-latest + if: ${{ github.event_name == 'push' }} steps: - uses: actions/checkout@v2 - uses: ./ + pr-build-test: + runs-on: ubuntu-latest + if: ${{ github.event_name == 'pull_request' }} + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 with: - milliseconds: 1000 + node-version: '12' + - run: | + npm install + - run: | + npm run build + - run: | + npm run package + - uses: ./ diff --git a/src/fetchChangelogs.ts b/src/fetchChangelogs.ts new file mode 100644 index 0000000..9849b18 --- /dev/null +++ b/src/fetchChangelogs.ts @@ -0,0 +1,23 @@ +import {getManagerConfig, RenovateConfig} from 'renovate/dist/config' +import {getChangeLogJSON} from 'renovate/dist/workers/pr/changelog' +import {UpdatedDependency, UpdatedDependencyWithChangelog} from './types' + +export async function fetchChangelogs( + config: RenovateConfig, + dependencies: UpdatedDependency[] +): Promise { + const result: UpdatedDependencyWithChangelog[] = [] + for (const updatedDependency of dependencies) { + const {dependency, update, manager} = updatedDependency + const logJSON = await getChangeLogJSON({ + branchName: '', + ...getManagerConfig(config, manager), + ...dependency, + ...update + }) + + result.push({...updatedDependency, changelog: logJSON}) + } + + return result +} diff --git a/src/getPrCommentBody.ts b/src/getPrCommentBody.ts new file mode 100644 index 0000000..940f576 --- /dev/null +++ b/src/getPrCommentBody.ts @@ -0,0 +1,111 @@ +import {PackageDependency} from 'renovate/dist/manager/types' +import {ChangeLogResult} from 'renovate/dist/workers/pr/changelog' +import {sanitizeMarkdown} from 'renovate/dist/util/markdown' +import {UpdatedDependencyWithChangelog} from './types' + +export const commentTitle = '# Dependency updates summary' +const footer = + '\n---\n\nThis comment content is generated by [Renovate Bot](https://github.com/renovatebot/renovate)' + +export function getPrCommentBody( + dependencies: UpdatedDependencyWithChangelog[] +): string { + const content = dependencies.map(getDependencyChangeContent) + return `${commentTitle} +This PR contains the following updates: + + +${content.map(x => x.tableRow).join('\n\n')} + +
+ +--- + +### Release notes +${content.map(x => x.changelog).join('\n\n')} + +${footer}` +} + +function getDependencyChangeContent({ + dependency, + update, + changelog +}: UpdatedDependencyWithChangelog): {tableRow: string; changelog: string} { + const dependencyLink = getDependencyNameLinked(dependency) + const type = dependency.prettyDepType ?? dependency.depType + const from = update.displayFrom ?? update.currentVersion + const to = update.displayTo ?? update.newVersion + + const change = `${from}${to}` + + return { + tableRow: ` +${dependencyLink} +${type} +${change} + +`, + changelog: `
${dependency.depName} + ${getReleaseNotes(dependencyLink, changelog)}
` + } +} + +function getReleaseNotes( + dependencyLink: string, + changelog: ChangeLogResult | null +): string { + const releases = + changelog?.versions?.map(x => { + const versionWithPrefix = x.version.startsWith('v') + ? x.version + : `v${x.version}` + + const header = x.releaseNotes + ? `### [\`${versionWithPrefix}\`](${x.releaseNotes.url})` + : `### \`${versionWithPrefix}\`` + + return `${header} +${x.compare.url ? `[Compare Source](${x.compare.url})` : ''} +${x.releaseNotes?.body ?? ''}` + }) ?? [] + + if (releases.length === 0) { + return `

No changelog found, please review changelog from official resources of ${dependencyLink}

` + } + + return sanitizeMarkdown(` +
+

+ +${releases.join('\n\n')} + +
`) +} + +function getDependencyNameLinked({ + depName, + homepage, + sourceUrl, + dependencyUrl, + changelogUrl +}: // eslint-disable-next-line @typescript-eslint/no-explicit-any +PackageDependency & Record): string { + let depNameLinked = depName || '' + const primaryLink = homepage || sourceUrl || dependencyUrl + if (primaryLink) { + depNameLinked = `${depNameLinked}` + } + const otherLinks = [] + if (homepage && sourceUrl) { + otherLinks.push(`source`) + } + if (changelogUrl) { + otherLinks.push(`changelog`) + } + if (otherLinks.length) { + depNameLinked += ` (${otherLinks.join(', ')})` + } + + return depNameLinked +} diff --git a/src/getRenovateConfig.ts b/src/getRenovateConfig.ts new file mode 100644 index 0000000..4023f54 --- /dev/null +++ b/src/getRenovateConfig.ts @@ -0,0 +1,54 @@ +import {parseConfigs, RenovateConfig} from 'renovate/dist/config' +import {setUtilConfig} from 'renovate/dist/util' +import {getRepositoryConfig} from 'renovate/dist/workers/global' +import {globalInitialize} from 'renovate/dist/workers/global/initialize' +import {initRepo} from 'renovate/dist/workers/repository/init' + +export async function getRenovateConfig({ + token, + owner, + repo +}: { + token: string + owner: string + repo: string +}): Promise { + const globalConfig = await parseConfigs( + { + ...process.env, + GITHUB_COM_TOKEN: token + }, + [ + // this might prevent renovate from making changes to the repository + '--dry-run', + 'true', + // this prevents renovate from creating the onboarding branch + '--onboarding', + 'false', + // this prevents renovate from complaining that the onboarding branch does not exist + '--require-config', + 'false', + '--token', + token + ] + ) + + // not sure if it's necessary, but it probably is, renovate uses this setting to use the locked version as the current version + globalConfig.rangeStrategy = 'update-lockfile' + // username and gitAuthor are only necessary for writing data, we only use Renovate to read data + globalConfig.gitAuthor = + 'github-actions <41898282+github-actions[bot]@users.noreply.github.com>' + globalConfig.username = 'github-actions[bot]' + // otherwise renovate will only be able to work with branch with `renovate/` prefix + globalConfig.branchPrefix = '' + + // this is necessary to get only one update from renovate, so we can just replace the latest version with the verion from the branch + globalConfig.separateMajorMinor = false + + let config = await globalInitialize(globalConfig) + + config = await getRepositoryConfig(config, `${owner}/${repo}`) + await setUtilConfig(config) + + return await initRepo(config) +} diff --git a/src/getUpdatedDependencies.ts b/src/getUpdatedDependencies.ts new file mode 100644 index 0000000..5032ecd --- /dev/null +++ b/src/getUpdatedDependencies.ts @@ -0,0 +1,69 @@ +import {PackageDependency, PackageFile} from 'renovate/dist/manager/types' +import {UpdatedDependency} from './types' + +export function* getUpdatedDependencies( + baseDependencies: Record, + headDependencies: Record +): IterableIterator { + for (const managerName in baseDependencies) { + const basePackageList = baseDependencies[managerName] + const headPackageList = headDependencies[managerName] + + if ( + !headPackageList || + headPackageList.length === 0 || + basePackageList.length === 0 + ) { + continue + } + + for (const basePackage of basePackageList) { + const headPackage = headPackageList.find( + x => x.packageFile === basePackage.packageFile + ) + + if (!headPackage) { + // the package seems to be removed from the head + continue + } + + for (const baseDependency of basePackage.deps) { + const headDependency = headPackage.deps.find( + x => + x.depName === baseDependency.depName && + x.depType === baseDependency.depType + ) + + if (!headDependency) { + // the dependency seems to be removed from the head + continue + } + + if (!isSameVersion(baseDependency, headDependency)) { + if (!baseDependency.updates || baseDependency.updates.length === 0) { + continue + } + + const [update] = baseDependency.updates // there should be a single update because we `fetchUpdates` on the base and use the rangeStrategy of 'update-lockfile' + yield { + manager: managerName, + packageFile: basePackage, + update, + dependency: baseDependency + } + } + } + } + } +} + +function isSameVersion( + a: PackageDependency>, + b: PackageDependency> +): boolean { + if (a.lockedVersion && b.lockedVersion) { + return a.lockedVersion === b.lockedVersion + } + + return a.currentValue === b.currentValue +} diff --git a/src/githubActionsBunyanStream.ts b/src/githubActionsBunyanStream.ts new file mode 100644 index 0000000..c45f8b7 --- /dev/null +++ b/src/githubActionsBunyanStream.ts @@ -0,0 +1,35 @@ +import {ERROR, INFO, Stream, WARN} from 'bunyan' +import {BunyanRecord} from 'renovate/dist/logger/utils' +import * as core from '@actions/core' +import {Writable} from 'stream' + +class GithubActionsStream extends Writable { + constructor() { + super({ + objectMode: true + }) + } + + _write(rec: BunyanRecord, _: unknown, next: () => void): void { + if (rec.level < INFO) { + core.debug(rec.msg) + } else if (rec.level < WARN) { + core.info(rec.msg) + } else if (rec.level < ERROR) { + core.warning(rec.msg) + } else { + core.error(rec.msg) + } + + next() + } +} + +export function createGithubActionsBunyanStream(): Stream { + return { + name: 'github-actions', + level: 'debug', + stream: new GithubActionsStream(), + type: 'raw' + } +} diff --git a/src/main.ts b/src/main.ts index c1574d0..ee5446a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,16 +1,84 @@ import * as core from '@actions/core' -import {wait} from './wait' +import {context, getOctokit} from '@actions/github' +import {PullRequestEvent} from '@octokit/webhooks-definitions/schema' +import {addStream} from 'renovate/dist/logger' +import {extractAllDependencies} from 'renovate/dist/workers/repository/extract' +import {fetchUpdates} from 'renovate/dist/workers/repository/process/fetch' +import simpleGit from 'simple-git' +import {fetchChangelogs} from './fetchChangelogs' +import {commentTitle, getPrCommentBody} from './getPrCommentBody' +import {getRenovateConfig} from './getRenovateConfig' +import {getUpdatedDependencies} from './getUpdatedDependencies' +import {createGithubActionsBunyanStream} from './githubActionsBunyanStream' +import {upsertPrComment} from './upsertPrComment' async function run(): Promise { try { - const ms: string = core.getInput('milliseconds') - core.debug(`Waiting ${ms} milliseconds ...`) // debug is only output if you set the secret `ACTIONS_RUNNER_DEBUG` to true + if (context.eventName !== 'pull_request') { + throw new Error( + 'The action can out run on pull_request workflow events. Please ensure your workflow is only triggered by pull_request events or run this action conditionally.' + ) + } - core.debug(new Date().toTimeString()) - await wait(parseInt(ms, 10)) - core.debug(new Date().toTimeString()) + const pullRequestPayload = context.payload as PullRequestEvent - core.setOutput('time', new Date().toTimeString()) + const { + pull_request: { + number: pullRequestNumber, + base: {sha: baseSha}, + head: {sha: headSha} + } + } = pullRequestPayload + + const token = core.getInput('token') + + core.debug(`Configuring renovate`) + addStream(createGithubActionsBunyanStream()) + + const config = await getRenovateConfig({...context.repo, token}) + const git = simpleGit(config.localDir) + + core.debug(`Checking out PR base sha ${baseSha}`) + await git.checkout(baseSha) + + core.debug(`Looking for all dependencies in base`) + const baseDependencies = await extractAllDependencies(config) + + core.debug(`Fetching possible updates for all base ref dependencies`) + await fetchUpdates(config, baseDependencies) + + core.debug(`Checking out PR head sha ${headSha}`) + await git.checkout(headSha) + + core.debug(`Looking for all dependencies in head`) + const headDependencies = await extractAllDependencies(config) + + const updatedDependencies = [ + ...getUpdatedDependencies(baseDependencies, headDependencies) + ] + + if (updatedDependencies.length > 0) { + core.info(`Found ${updatedDependencies.length} updated dependencies`) + } else { + core.info(`No updated dependencies, exiting`) + return + } + + const updatedDependenciesWithChangelogs = await fetchChangelogs( + config, + updatedDependencies + ) + const commentBody = getPrCommentBody(updatedDependenciesWithChangelogs) + + const github = getOctokit(token) + + await upsertPrComment( + github, + context.repo, + pullRequestNumber, + commentTitle, + commentBody + ) } catch (error) { core.setFailed(error.message) } diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..a700b6c --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,17 @@ +import { + LookupUpdate, + PackageDependency, + PackageFile +} from 'renovate/dist/manager/types' +import {ChangeLogResult} from 'renovate/dist/workers/pr/changelog' + +export interface UpdatedDependency { + manager: string + packageFile: PackageFile + dependency: PackageDependency> + update: LookupUpdate +} + +export interface UpdatedDependencyWithChangelog extends UpdatedDependency { + changelog: ChangeLogResult | null +} diff --git a/src/upsertPrComment.ts b/src/upsertPrComment.ts new file mode 100644 index 0000000..fb805db --- /dev/null +++ b/src/upsertPrComment.ts @@ -0,0 +1,35 @@ +type Octokit = ReturnType + +export async function upsertPrComment( + github: Octokit, + repo: { + owner: string + repo: string + }, + pullRequestNumber: number, + title: string, + body: string +): Promise { + const existingCommentsResponse = await github.issues.listComments({ + ...repo, + issue_number: pullRequestNumber + }) + + const [existingComment] = existingCommentsResponse.data.filter(x => + x.body?.startsWith(title) + ) + + if (existingComment) { + await github.issues.updateComment({ + ...repo, + comment_id: existingComment.id, + body + }) + } else { + await github.issues.createComment({ + ...repo, + issue_number: pullRequestNumber, + body + }) + } +}