Skip to content

Commit

Permalink
feat: add full option, output all versions with body or error
Browse files Browse the repository at this point in the history
  • Loading branch information
jedwards1211 committed May 3, 2019
1 parent f8113be commit a1b836d
Show file tree
Hide file tree
Showing 4 changed files with 125 additions and 86 deletions.
22 changes: 14 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,35 @@ list breaking changes in newer major versions of packages
# How it works

Right now only packages hosted on GitHub are supported. `what-broke` will get
the package info and repository URL from `npm`, then try to fetch and parse the
package's `CHANGELOG.md` or `changelog.md` in the `master` branch.
If no changelog file exists it will try to fetch GitHub releases instead
(which work way better for a tool like this than changelog files, so please use
GitHub releases!)
the package info and repository URL from `npm`, then try to fetch the GitHub
release for each relevant version tag. If no GitHub release is found it will
fall back to trying to parse the package's `CHANGELOG.md` or `changelog.md`.
GitHub releases are way more reliable for this purpose though, so please use
them!

# GitHub token
# API Tokens

GitHub heavily rate limits public API requests, but allows more throughput for
authenticated requests. If you set the `GH_TOKEN` environment variable to a
personal access token, `what-broke` will use it when requesting GitHub releases.

`what-broke` will also use the `NPM_TOKEN` environment variable or try to get
the npm token from your `~/.npmrc`, so that it can get information for private
packages you request.

# CLI

```
npm i -g what-broke
```

```
what-broke <package> [<from verison> [<to version>]]
what-broke <package> [--full] [<from verison> [<to version>]]
```

Will print out the changelog contents for all major and prerelease versions in
the given range.
the given range. (If `--full` is given, it will also include minor and patch
versions.)

If `package` is installed in the current working directory, `<from version>`
will default to the installed version.
Expand All @@ -53,6 +58,7 @@ async function whatBroke(
options?: {
fromVersion?: ?string,
toVersion?: ?string,
full?: ?boolean,
}
): Promise<Array<{version: string, body: string}>>
```
7 changes: 4 additions & 3 deletions src/changelog-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ export type Release = {
version: string,
body?: string,
date?: Date,
error?: Error,
}

export default function parseChangelog(text: string): Array<Release> {
const result = []
export default function parseChangelog(text: string): { [string]: Release } {
const result = {}
const versionHeaderRx = new RegExp(
`^#+\\s+(${versionRx})(.*)$|^(${versionRx})(.*)(\r\n?|\n)=+`,
'mg'
Expand All @@ -22,7 +23,7 @@ export default function parseChangelog(text: string): Array<Release> {
const version = match[1] || match[4]
if (release) release.body = text.substring(start, match.index).trim()
release = { version }
result.push(release)
result[version] = release
start = match.index + match[0].length
}
if (release) release.body = text.substring(start).trim()
Expand Down
164 changes: 89 additions & 75 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,114 +8,125 @@ import parseChangelog, { type Release } from './changelog-parser'
import semver from 'semver'
import Octokit from '@octokit/rest'
import getNpmToken from './getNpmToken'
import memoize from './util/memoize'

const { GH_TOKEN } = process.env

const octokitOptions = {}
if (GH_TOKEN) octokitOptions.auth = `token ${GH_TOKEN}`
const octokit = new Octokit(octokitOptions)

export async function getChangelog(
owner: string,
repo: string
): Promise<Array<Release>> {
let changelog
for (const file of ['CHANGELOG.md', 'changelog.md']) {
try {
const {
data: { content },
} = await octokit.repos.getContents({
owner,
repo,
path: file,
})
changelog = Base64.decode(content)
break
} catch (error) {
continue
export const getChangelog = memoize(
async (owner: string, repo: string): Promise<{ [string]: Release }> => {
let changelog
for (const file of ['CHANGELOG.md', 'changelog.md']) {
try {
const {
data: { content },
} = await octokit.repos.getContents({
owner,
repo,
path: file,
})
changelog = Base64.decode(content)
break
} catch (error) {
continue
}
}
}
if (!changelog) throw new Error('failed to get changelog')
return await parseChangelog(changelog)
if (!changelog) throw new Error('failed to get changelog')
return await parseChangelog(changelog)
},
(owner, repo) => `${owner}/${repo}`
)

function parseRepositoryUrl(url: string): { owner: string, repo: string } {
const match = /github\.com[:/]([^\\]+)\/([^.\\]+)/i.exec(url)
if (!match) throw new Error(`repository.url not supported: ${url}`)
const [owner, repo] = match.slice(1)
return { owner, repo }
}

export async function whatBroke(
pkg: string,
{
fromVersion,
toVersion,
full,
}: {
fromVersion?: ?string,
toVersion?: ?string,
full?: ?boolean,
} = {}
): Promise<Object> {
const npmInfo = await npmRegistryFetch.json(pkg, {
token: await getNpmToken(),
})
const { repository: { url } = {} } = npmInfo
if (!url) throw new Error('failed to get repository.url')
const match = /github\.com\/([^\\]+)\/([^.\\]+)/i.exec(url)
if (!match) throw new Error(`repository.url not supported: ${url}`)
const [owner, repo] = match.slice(1)
let changelog
await getChangelog(owner, repo)
.then(c => (changelog = c))
.catch(() => {})

const result = []
const versions = Object.keys(npmInfo.versions).filter(
(v: string): boolean => {
if (fromVersion && !semver.gt(v, fromVersion)) return false
if (toVersion && !semver.lt(v, toVersion)) return false
return true
}
)

const releases = []

if (changelog) {
let prevVersion = fromVersion
for (const release of changelog.reverse()) {
const { version } = release
if (!version) continue
if (prevVersion && semver.lte(version, prevVersion)) continue
if (toVersion && semver.gt(version, toVersion)) break
if (
prevVersion == null ||
semver.prerelease(version) ||
!semver.satisfies(version, `^${prevVersion}`)
) {
result.push(release)
prevVersion = version
}
let prevVersion = fromVersion
for (let version of versions) {
if (
!full &&
prevVersion != null &&
!semver.prerelease(version) &&
semver.satisfies(version, `^${prevVersion}`) &&
!(semver.prerelease(prevVersion) && !semver.prerelease(version))
) {
continue
}
} else {
const versions = Object.keys(npmInfo.versions)
prevVersion = version

const release: Release = { version, date: new Date(npmInfo.time[version]) }
releases.push(release)

let prevVersion = fromVersion
for (const version of versions) {
if (!version) continue
if (prevVersion && semver.lte(version, prevVersion)) continue
if (toVersion && semver.gt(version, toVersion)) break
if (
prevVersion == null ||
semver.prerelease(version) ||
!semver.satisfies(version, `^${prevVersion}`)
) {
await octokit.repos
.getReleaseByTag({
owner,
repo,
tag: `v${version}`,
})
.then(({ data: { body } }: Object) => {
result.push({ version, body })
})
.catch(() => {})
const { url } =
npmInfo.versions[version].repository || npmInfo.repository || {}
if (!url) {
release.error = new Error('failed to get repository url from npm')
}

prevVersion = version
try {
const { owner, repo } = parseRepositoryUrl(url)

try {
release.body = (await octokit.repos.getReleaseByTag({
owner,
repo,
tag: `v${version}`,
})).data.body
} catch (error) {
const changelog = await getChangelog(owner, repo)
if (changelog[version]) release.body = changelog[version].body
}
if (!release.body) {
release.error = new Error(
`failed to find GitHub release or changelog entry for version ${version}`
)
}
} catch (error) {
release.error = error
}
}

return result
return releases
}

if (!module.parent) {
const pkg = process.argv[2]
let fromVersion = process.argv[3],
toVersion = process.argv[4]
const full = process.argv.indexOf('--full') >= 0
const args = process.argv.slice(2).filter(a => a[0] !== '-')
const pkg = args[0]
let fromVersion = args[1],
toVersion = args[2]
if (!fromVersion) {
try {
// $FlowFixMe
Expand All @@ -130,11 +141,14 @@ if (!module.parent) {
}
}
/* eslint-env node */
whatBroke(pkg, { fromVersion, toVersion }).then(
whatBroke(pkg, { fromVersion, toVersion, full }).then(
(changelog: Array<Release>) => {
for (const { version, body } of changelog) {
for (const { version, body, error } of changelog) {
process.stdout.write(chalk.bold(version) + '\n\n')
if (body) process.stdout.write(body + '\n\n')
if (error) {
process.stdout.write(`Failed to get changelog: ${error.stack}\n\n`)
}
}
process.exit(0)
},
Expand Down
18 changes: 18 additions & 0 deletions src/util/memoize.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* @flow
* @prettier
*/

export default function memoize<F: (...args: any[]) => any>(
fn: F,
resolver?: (...args: any[]) => any = first => String(first)
): F {
const cache = new Map()
return ((...args: any[]): any => {
const key = resolver(...args)
if (cache.has(key)) return cache.get(key)
const result = fn(...args)
cache.set(key, result)
return result
}: any)
}

0 comments on commit a1b836d

Please sign in to comment.