From 0db8c385f694999f048dd800abcfced3a85223a9 Mon Sep 17 00:00:00 2001 From: ndunnett Date: Sat, 6 Apr 2024 22:22:05 +0000 Subject: [PATCH] update-badge: rewrite to use GraphQL and process multiple files --- .github/actions/update-badge/README.md | 8 +- .github/actions/update-badge/action.yml | 12 +- .../{endpoint.js => generateBadge.js} | 74 +++++------- .github/actions/update-badge/index.js | 114 +++++++++--------- .github/actions/update-badge/updateRepos.js | 75 ++++++++++++ 5 files changed, 169 insertions(+), 114 deletions(-) rename .github/actions/update-badge/{endpoint.js => generateBadge.js} (58%) create mode 100644 .github/actions/update-badge/updateRepos.js diff --git a/.github/actions/update-badge/README.md b/.github/actions/update-badge/README.md index c684ac0d..6ca78e92 100644 --- a/.github/actions/update-badge/README.md +++ b/.github/actions/update-badge/README.md @@ -8,13 +8,9 @@ This action fetches last commit time of repositories and generates the badge for **Required** GitHub token -### `inputFile` +### `fileNames` -**Required** File path of README.md - -### `outputFile` - -**Required** File path of updated README.md +**Required** Names of files to process (space delimited list) ## Outputs diff --git a/.github/actions/update-badge/action.yml b/.github/actions/update-badge/action.yml index 04a5e90d..88c9ea8e 100644 --- a/.github/actions/update-badge/action.yml +++ b/.github/actions/update-badge/action.yml @@ -4,14 +4,10 @@ inputs: ghToken: description: 'GitHub token' required: true - inputFile: - description: 'File path of README.md' + fileNames: + description: 'Names of files to process (space delimited list)' required: true default: 'README.md' - outputFile: - description: 'File path of output' - required: true - default: 'README-badge.md' runs: - using: 'node12' - main: 'index.js' \ No newline at end of file + using: 'node20' + main: 'index.js' diff --git a/.github/actions/update-badge/endpoint.js b/.github/actions/update-badge/generateBadge.js similarity index 58% rename from .github/actions/update-badge/endpoint.js rename to .github/actions/update-badge/generateBadge.js index a58bb2ce..ff32dbb9 100644 --- a/.github/actions/update-badge/endpoint.js +++ b/.github/actions/update-badge/generateBadge.js @@ -1,45 +1,47 @@ -// modifiy based on https://runkit.com/melnikow/version-from-requirements-txt -const axios = require('axios'); -const moment = require('moment'); +const moment = require('moment') const badgeDefaults = { schemaVersion: 1, label: 'last commit', + message: 'not available', + color: 'red' } -async function handle(octokit, { owner, repo }) { - let lastCommitDayStr - try { - const { data: commitList } = await Promise.race([ - new Promise(resolve => setTimeout(resolve, 10 * 1000)), - octokit.repos.listCommits({ - owner, - repo - }) - ]) - lastCommitDayStr = commitList[0].commit.author.date - } catch (e) { - if (e.message.indexOf('rate limit exceeded') !== -1) { - throw e; - } +module.exports = function generateBadge (repo) { + const { label, message, color } = badgeProperties(repo) + return ( + 'https://img.shields.io/badge/' + + [label, message, color] + .map(s => encodeURIComponent(s.replace(/\-/g, '--'))) + .join('-') + ) +} - console.log(`[${owner }/${repo}] ${e.message}`) - return { message: 'resource not available', color: 'red' } - } - - const message = moment(lastCommitDayStr).format('YYYY-MM-DD') - return { - message, - color: age(message), +function badgeProperties (repo) { + if (repo.lastCommit === undefined) { + return badgeDefaults + } else { + const message = moment(repo.lastCommit).format('YYYY-MM-DD') + return Object.assign({}, badgeDefaults, { message, color: age(message) }) } } -module.exports = async function invokeHandler(octokit, query) { - return Object.assign({}, badgeDefaults, await handle(octokit, query)) +function age (date) { + if (!date.add) { + return 'brightgreen' + } + + const now = moment() + const then = moment(date) + if (now.diff(date) < 0) date.add(-1, 'y') + + const daysElapsed = now.diff(moment(date), 'days') + const colorByAge = colorScale([0, 5, 10, 20, 25], undefined, false) + return colorByAge(daysElapsed) } // copy from https://github.com/badges/shields -function colorScale(steps, colors, reversed) { +function colorScale (steps, colors, reversed) { if (steps === undefined) { throw Error('When invoking colorScale, steps should be provided.') } @@ -78,17 +80,3 @@ function colorScale(steps, colors, reversed) { return colors.slice(stepIndex)[0] } } - -function age(date) { - if (!date.add) { - return "brightgreen" - } - - const now = moment() - const then = moment(date) - if (now.diff(date) < 0) date.add(-1, 'y') - - const daysElapsed = now.diff(moment(date), 'days') - const colorByAge = colorScale([0, 5, 10, 20, 25], undefined, false) - return colorByAge(daysElapsed) -} diff --git a/.github/actions/update-badge/index.js b/.github/actions/update-badge/index.js index 7ee9acb7..f517ff2b 100644 --- a/.github/actions/update-badge/index.js +++ b/.github/actions/update-badge/index.js @@ -1,62 +1,79 @@ const fs = require('fs') const core = require('@actions/core') const github = require('@actions/github') -const endpoint = require('./endpoint') +const generateBadge = require('./generateBadge') +const updateRepos = require('./updateRepos') -let octokit +;(async () => { + const ghToken = core.getInput('ghToken') + const fileNames = core.getInput('fileNames').split(' ') + let octokit = github.getOctokit(ghToken) -(async function() { + for (const fileName of fileNames) { + await processFile(fileName, octokit) + } +})() + +async function processFile (fileName, octokit) { try { - const ghToken = core.getInput('ghToken') - const inputFile = core.getInput('inputFile') - const outputFile = core.getInput('outputFile') + const lines = await core.group(`Reading from '${fileName}'...`, () => + fs.readFileSync(fileName, 'utf8').split('\n') + ) + + let repos = await core.group('Parsing repositories...', () => + parseGithubRepos(lines) + ) + + repos = await core.group('Querying repositories...', async () => { + return await updateRepos(octokit, repos) + }) - const content = fs.readFileSync(inputFile, 'utf8') - const lines = content.split('\n') - const repos = await core.group('Extracting repos...', () => extractRepositories(lines)) core.info(`count=${repos.length}`) - octokit = github.getOctokit(ghToken) - await core.group('Fetching repositories & updating lines...', async () => { + await core.group('Updating badges...', async () => { for (const repo of repos) { - const line = await generateLine(repo.repoStr) - if (otherDomain(lines[repo.index])) { - core.info(`...line refers to non-GH domain: '${lines[repo.index]}'`) - } else if (shouldUpdate(lines[repo.index], line)) { - lines[repo.index] = line - core.info(`...updated line: '${line}'`) + const line = generateLine(repo) + + if (line === lines[repo.index]) { + core.info(`...skipped line ${repo.index + 1}: '${line}'`) } else { - core.info(`...skipped line: '${line}'`) + lines[repo.index] = line + core.info(`...updated line ${repo.index + 1}: '${line}'`) } } }) - await core.group('Writing README...', () => { - fs.writeFileSync(outputFile, lines.join('\n')) - core.info(`Finished writing to ${outputFile}`) + await core.group('Writing file...', () => { + fs.writeFileSync(fileName, lines.join('\n')) + core.info(`Finished writing to '${fileName}'`) }) } catch (error) { core.setFailed(error.message) } -})() +} -function extractRepositories(lines) { +function parseGithubRepos (lines) { const repos = [] - let collect = false + lines.some((line, index) => { if (line === '### Solutions') { collect = true } else if (line === '### Live Streams') { collect = false } else if (collect) { - const idx1 = line.indexOf('[') - const idx2 = line.indexOf(']') - if (idx1 >= 0 && idx2 >= 0) { - repos.push({ - index, - repoStr: line.slice(idx1 + 1, idx2) - }) + if (otherDomain(line)) { + core.info(`...line refers to non-GH domain: '${line}'`) + } else { + const idx1 = line.indexOf('[') + const idx2 = line.indexOf(']') + + if (idx1 >= 0 && idx2 >= 0) { + const repoStr = line.slice(idx1 + 1, idx2) + const [owner, name] = repoStr.split('/') + repos.push({ index, owner, name }) + core.info(`...parsed repo: '${owner}/${name}'`) + } } } @@ -66,31 +83,14 @@ function extractRepositories(lines) { return repos } -async function generateLine(repoStr) { - const badge = await generateBadge(repoStr) +function otherDomain (line) { + return ( + line.indexOf('gitlab.com') !== -1 || line.indexOf('gist.github.com') !== -1 + ) +} + +function generateLine (repo) { + const badge = generateBadge(repo) + const repoStr = `${repo.owner}/${repo.name}` return `* [${repoStr}](https://github.com/${repoStr}) ![Last Commit on GitHub](${badge})` } - -async function generateBadge(repoStr) { - const [owner, repo] = repoStr.split('/') - const { label, message, color } = await endpoint(octokit, { owner, repo }) - - core.info(`...fetched repo ${repoStr}`) - - return 'https://img.shields.io/badge/' + [label, message, color] - .map(s => encodeURIComponent(s.replace(/\-/g, '--'))) - .join('-') -} - -function shouldUpdate(oldLine, newLine) { - const lastReg = /Last Commit on GitHub/; - const badDateReg = /red\)$/; - return !lastReg.test(oldLine) || - !badDateReg.test(newLine) || - badDateReg.test(oldLine); -} - -function otherDomain(line) { - return line.indexOf("gitlab.com") !== -1 || - line.indexOf("gist.github.com") !== -1; -} diff --git a/.github/actions/update-badge/updateRepos.js b/.github/actions/update-badge/updateRepos.js new file mode 100644 index 00000000..6ccd3ba1 --- /dev/null +++ b/.github/actions/update-badge/updateRepos.js @@ -0,0 +1,75 @@ +const core = require('@actions/core') + +// limit size of each request to avoid getting timed out +const chunkSize = 200 + +module.exports = async function updateRepos (octokit, repos) { + let responses = [] + + for (let i = 0; i < repos.length; i += chunkSize) { + const chunk = repos.slice(i, i + chunkSize) + responses.push(await queryRepos(octokit, chunk)) + } + + return responses.flatMap(response => { + return Object.entries(response).map(([key, obj]) => { + const index = parseInt(key.replace('index', '')) + + if (obj === null) { + return repos.find(repo => repo.index === index) + } else { + return { + index, + owner: obj.owner.login, + name: obj.name, + lastCommit: obj.defaultBranchRef.target.committedDate + } + } + }) + }) +} + +async function queryRepos (octokit, repos) { + const query = buildQuery(repos) + + try { + return await octokit.graphql(query) + } catch (error) { + if (error.errors !== undefined) { + const softFail = error.errors.every( + e => e.message.indexOf('Could not resolve to a Repository') !== -1 + ) + + if (softFail) { + core.info(error) + return error.data + } + } + + throw error + } +} + +function buildQuery (repos) { + const frag = `fragment props on Repository { + owner { + login + } + name + defaultBranchRef { + target { + ... on Commit { + committedDate + } + } + } +}` + + const query = repos + .map(repo => { + return `index${repo.index}: repository(owner: "${repo.owner}", name: "${repo.name}") { ...props }` + }) + .join('\n') + + return `${frag}\n\n{\n${query}\n}` +}