diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index ef07cd0..548c716 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 == 'pull_request' }}
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
+ })
+ }
+}