import cheerio from 'cheerio' import distanceToNow from 'date-fns/formatDistanceToNow' import got from '../libs/got' import { restGithub, queryGithub } from '../libs/github' import { createBadgenHandler, PathArgs } from '../libs/create-badgen-handler' import { version, millify, coverageColor } from '../libs/utils' export default createBadgenHandler({ title: 'GitHub', examples: { '/github/release/babel/babel': 'latest release', '/github/release/babel/babel/stable': 'latest stable release', '/github/tag/micromatch/micromatch': 'latest tag', '/github/watchers/micromatch/micromatch': 'watchers', '/github/checks/tunnckoCore/opensource': 'combined checks (default branch)', '/github/checks/node-formidable/node-formidable': 'combined checks (default branch)', '/github/checks/node-formidable/node-formidable/master/lint': 'single checks (lint job)', '/github/checks/node-formidable/node-formidable/master/test': 'single checks (test job)', '/github/checks/node-formidable/node-formidable/master/ubuntu?label=linux': 'single checks (linux)', '/github/checks/node-formidable/node-formidable/master/windows': 'single checks (windows)', '/github/checks/node-formidable/node-formidable/master/macos': 'single checks (macos)', '/github/checks/styfle/packagephobia/main': 'combined checks (branch)', '/github/status/micromatch/micromatch': 'combined statuses (master branch)', '/github/status/micromatch/micromatch/gh-pages': 'combined statuses (branch)', '/github/status/micromatch/micromatch/f4809eb6df80b': 'combined statuses (commit)', '/github/status/micromatch/micromatch/4.0.1': 'combined statuses (tag)', '/github/status/facebook/react/master/ci/circleci:%20yarn_test': 'single status', '/github/status/zeit/hyper/master/ci': 'combined statuses (ci*)', '/github/status/zeit/hyper/master/ci/circleci': 'combined statuses (ci/circleci*)', '/github/status/zeit/hyper/master/ci/circleci:%20build': 'single status', '/github/stars/micromatch/micromatch': 'stars', '/github/forks/micromatch/micromatch': 'forks', '/github/issues/micromatch/micromatch': 'issues', '/github/open-issues/micromatch/micromatch': 'open issues', '/github/closed-issues/micromatch/micromatch': 'closed issues', '/github/label-issues/nodejs/node/ES%20Modules': 'issues by label', '/github/label-issues/atom/atom/help-wanted/open': 'open issues by label', '/github/label-issues/rust-lang/rust/B-RFC-approved/closed': 'closed issues by label', '/github/prs/micromatch/micromatch': 'PRs', '/github/open-prs/micromatch/micromatch': 'open PRs', '/github/closed-prs/micromatch/micromatch': 'closed PRs', '/github/merged-prs/micromatch/micromatch': 'merged PRs', '/github/milestones/chrislgarry/Apollo-11/1': 'milestone percentage', '/github/commits/micromatch/micromatch': 'commits count', '/github/commits/micromatch/micromatch/gh-pages': 'commits count (branch ref)', '/github/commits/micromatch/micromatch/4.0.1': 'commits count (tag ref)', '/github/last-commit/micromatch/micromatch': 'last commit', '/github/last-commit/micromatch/micromatch/gh-pages': 'last commit (branch ref)', '/github/last-commit/micromatch/micromatch/4.0.1': 'last commit (tag ref)', '/github/branches/micromatch/micromatch': 'branches', '/github/releases/micromatch/micromatch': 'releases', '/github/tags/micromatch/micromatch': 'tags', '/github/license/micromatch/micromatch': 'license', '/github/contributors/micromatch/micromatch': 'contributors', '/github/assets-dl/electron/electron': 'assets downloads for latest release', '/github/assets-dl/electron/electron/v7.0.0': 'assets downloads for a tag', '/github/dependents-repo/micromatch/micromatch': 'repository dependents', '/github/dependents-pkg/micromatch/micromatch': 'package dependents', '/github/dependabot/ubuntu/yaru': 'dependabot status', }, handlers: { '/github/:topic/:owner/:repo': repoStats, '/github/:topic/:owner/:repo': repoStats, '/github/:topic/:owner/:repo': repoStats, '/github/:topic/:owner/:repo/:label/:states?': repoStats, '/github/:topic/:owner/:repo/:ref?': repoStats, '/github/:topic/:owner/:repo/:tag?': downloads, // `dt` is deprecated '/github/release/:owner/:repo/:channel?': release, '/github/checks/:owner/:repo/:ref?': checks, '/github/checks/:owner/:repo/:ref/:context+': checks, '/github/status/:owner/:repo/:ref?': status, '/github/status/:owner/:repo/:ref/:context+': status, '/github/contributors/:owner/:repo': contributors, '/github/milestones/:owner/:repo/:milestone_number': milestones, '/github/dependents-repo/:owner/:repo': dependents('REPOSITORY'), '/github/dependents-pkg/:owner/:repo': dependents('PACKAGE'), '/github/dependabot/:owner/:repo': dependabotStatus, } }) // https://developer.github.com/v3/repos/statuses/#get-the-combined-status-for-a-specific-ref const statesColor = { pending: 'orange', success: 'green', failure: 'red', error: 'red', unknown: 'grey' } function combined (states: Array, stateKey: string = 'state') { if (states.length === 0) return 'unknown' if (states.find(x => x[stateKey] === 'error')) return 'error' if (states.find(x => x[stateKey] === 'failure')) return 'failure' if (states.find(x => x[stateKey] === 'pending')) return 'pending' const succeeded = states .filter(x => x[stateKey] !== 'cancelled') .filter(x => x[stateKey] !== 'skipped') .every(x => x[stateKey] === 'success') if (succeeded) return 'success' // this shouldn't happen, but in case it happens throw new Error(`Unknown states: ${states.map(x => x[stateKey]).join()}`) } async function checks ({ owner, repo, ref = 'master', context}: PathArgs) { const resp = await restGithub(`repos/${owner}/${repo}/commits/${ref}/check-runs`, 'antiope') let state = typeof context === 'string' ? resp!.check_runs.filter(check => { const checkName = check.name.toLowerCase().includes(context.toLowerCase()) const appName = check.app.slug.toLowerCase().includes(context.toLowerCase()) return checkName || appName }) : resp!.check_runs if (Array.isArray(state)) { state = combined(state, 'conclusion') } if (state) { return { subject: context || 'checks', status: state, color: statesColor[state] } } else { return { subject: 'checks', status: 'unknown', color: 'grey' } } } async function status ({ owner, repo, ref = 'master', context }: PathArgs) { const resp = await restGithub(`repos/${owner}/${repo}/commits/${ref}/status`) let state = typeof context === 'string' ? resp!.statuses.filter(st => st.context.toLowerCase().includes(context.toLowerCase())) : resp!.state if (Array.isArray(state)) { state = combined(state, 'state') } if (state) { return { subject: context || 'status', status: state, color: statesColor[state] } } else { return { subject: 'status', status: 'unknown', color: 'grey' } } } async function release ({ owner, repo, channel }: PathArgs) { const releases = await restGithub(`repos/${owner}/${repo}/releases`) if (!releases || !releases.length) { return { subject: 'release', status: 'none', color: 'yellow' } } const [latest] = releases const stable = releases.find(release => !release.prerelease) switch (channel) { case 'stable': return { subject: 'release', status: version(stable ? stable.name || stable.tag_name : null), color: 'blue' } default: return { subject: 'release', status: version(latest.name || latest.tag_name), color: latest.prerelease ? 'orange' : 'blue' } } } async function contributors ({ owner, repo }: PathArgs) { const contributors = await restGithub(`repos/${owner}/${repo}/contributors`) return { subject: 'contributors', status: contributors.length, color: 'blue' } } async function downloads ({ owner, repo, tag }: PathArgs) { const releaseSelection = tag ? `tags/${tag}` : 'latest' const release = await restGithub(`repos/${owner}/${repo}/releases/${releaseSelection}`) if (!release || !release.assets || !release.assets.length) { return { subject: 'downloads', status: 'no assets', color: 'grey' } } /* eslint-disable camelcase */ const downloadCount = release.assets.reduce((result, { download_count }) => { return result + download_count }, 0) return { subject: 'downloads', status: millify(downloadCount), color: 'green' } } async function milestones ({ owner, repo, milestone_number }: PathArgs) { const milestone = await restGithub(`repos/${owner}/${repo}/milestones/${milestone_number}`) if (!milestone) { return { subject: 'milestones', status: 'no milestone', color: 'grey' } } const openIssues = milestone.open_issues const totalIssues = openIssues + milestone.closed_issues const percentage = totalIssues === 0 ? 0 : 100 - ((openIssues / totalIssues) * 100) return { subject: milestone.title, status: `${Math.floor(percentage)}%`, color: coverageColor(percentage) } } async function dependabotStatus({ owner, repo }: PathArgs) { // Since there is no API to get dependabot status, for now check if file exists const status = await restGithub(`repos/${owner}/${repo}/contents/.github/dependabot.yml`) if (status) { return { subject: 'dependabot', status: 'Active', color: 'green' } } } const makeRepoQuery = (topic, owner, repo, restArgs) => { const repoQueryBodies = { 'license': 'licenseInfo { spdxId }', 'watchers': 'watchers { totalCount }', 'stars': 'stargazers { totalCount }', 'forks': 'forks { totalCount }', 'issues': 'issues { totalCount }', 'open-issues': 'issues(states:[OPEN]) { totalCount }', 'closed-issues': 'issues(states:[CLOSED]) { totalCount }', 'prs': 'pullRequests { totalCount }', 'open-prs': 'pullRequests(states:[OPEN]) { totalCount }', 'closed-prs': 'pullRequests(states:[CLOSED, MERGED]) { totalCount }', 'merged-prs': 'pullRequests(states:[MERGED]) { totalCount }', 'branches': 'refs(first: 0, refPrefix: "refs/heads/") { totalCount }', 'releases': 'releases { totalCount }', 'tags': 'refs(first: 0, refPrefix: "refs/tags/") { totalCount }', 'tag': `refs(last: 1, refPrefix: "refs/tags/") { edges { node { name } } }` } let queryBody switch (topic) { case 'label-issues': const { label, states } = restArgs const issueFilter = states ? `(states:[${states.toUpperCase()}])` : '' queryBody = `label(name:"${label}") { color, issues${issueFilter} { totalCount } }` break case 'commits': queryBody = ` branch: ref(qualifiedName: "${restArgs.ref || 'master'}") { target { ... on Commit { history(first: 0) { totalCount } } } } ` break case 'last-commit': queryBody = ` branch: ref(qualifiedName: "${restArgs.ref || 'master'}") { target { ... on Commit { history(first: 1) { nodes { committedDate } } } } } ` break default: queryBody = repoQueryBodies[topic] } if (queryBody) { const query = ` query { repository(owner:"${owner}", name:"${repo}") { ${queryBody} } } ` return queryGithub(query).then(res => res.data!.repository) } } async function repoStats ({topic, owner, repo, ...restArgs}: PathArgs) { const result = await makeRepoQuery(topic, owner, repo, restArgs) if (!result) { return { subject: 'github', status: 'not found', color: 'grey' } } switch (topic) { case 'watchers': case 'forks': case 'issues': case 'releases': return { subject: topic, status: millify(result[topic].totalCount), color: 'blue' } case 'branches': case 'tags': return { subject: topic, status: millify(result.refs.totalCount), color: 'blue' } case 'stars': return { subject: topic, status: millify(result.stargazers.totalCount), color: 'blue' } case 'open-issues': return { subject: 'open issues', status: millify(result.issues.totalCount), color: result.issues.totalCount === 0 ? 'green' : 'orange' } case 'closed-issues': return { subject: 'closed issues', status: millify(result.issues.totalCount), color: 'blue' } case 'label-issues': return { subject: `${restArgs.label}`, status: result.label ? result.label.issues.totalCount : 0, color: result.label ? result.label.color : 'grey' } case 'prs': return { subject: 'PRs', status: millify(result.pullRequests.totalCount), color: 'blue' } case 'open-prs': return { subject: 'open PRs', status: millify(result.pullRequests.totalCount), color: 'blue' } case 'closed-prs': return { subject: 'closed PRs', status: millify(result.pullRequests.totalCount), color: 'blue' } case 'merged-prs': return { subject: 'merged PRs', status: millify(result.pullRequests.totalCount), color: 'blue' } case 'commits': return { subject: topic, status: millify(result.branch.target.history.totalCount), color: 'blue' } case 'tag': const tags = result.refs.edges const latestTag = tags.length > 0 ? tags[0].node.name : null return { subject: 'latest tag', status: version(latestTag), color: 'blue' } case 'license': const li = result.licenseInfo return { subject: topic, status: li ? li.spdxId : 'no license', color: li ? 'blue' : 'grey' } case 'last-commit': const commits = result.branch.target.history.nodes const lastDate = commits.length && new Date(commits[0].committedDate) const fromNow = lastDate && distanceToNow(lastDate, { addSuffix: true }) return { subject: 'last commit', status: fromNow || 'none', color: 'green' } default: return { subject: 'github', status: 'unknown topic', color: 'grey' } } } function dependents (type: string) { return async function ({ owner, repo }: PathArgs) { const html = await got(`https://github.com/${owner}/${repo}/network/dependents`, { headers: { Accept: 'text/html,application/xhtml+xml,application/xml' } }).text() return { subject: type === 'PACKAGE' ? 'pkg dependents' : 'repo dependents', status: parseDependents(html, type), color: 'blue' } } } const parseDependents = (html, type) => { const $ = cheerio.load(html) const depLink = $(`a[href$="?dependent_type=${type}"]`) if (depLink.length !== 1) { return 'unknown' } return depLink.text().replace(/[^0-9,]/g, '') }