Commit 4bae106

Anton Golub <antongolub@antongolub.com>
2024-12-03 21:12:29
refactor: use pipeline ctx (#81)
* refactor: use pipeline ctx * Update process-tasks.ts * Update process-tasks.ts --------- Co-authored-by: Anton Medvedev <anton@medv.io>
1 parent 40f375e
badges/star-gazer/star-gazer.ts
@@ -18,8 +18,14 @@ export default define({
     }
 
     if (selfStars.length >= 1) {
-      grant('self-star', `I've starred ${selfStars.length} my own repositories.`)
-        .evidence(selfStars.map((x) => `- <a href="${x.url}">${x.nameWithOwner}</a>`).join('\n'))
+      grant(
+        'self-star',
+        `I've starred ${selfStars.length} my own repositories.`,
+      ).evidence(
+        selfStars
+          .map((x) => `- <a href="${x.url}">${x.nameWithOwner}</a>`)
+          .join('\n'),
+      )
     }
   },
 })
scripts/megaera.mjs
@@ -1,5 +1,7 @@
 import 'zx/globals'
 
-await $`megaera --schema .github/schema.graphql ${await glob('src/**/*.graphql')}`.verbose()
+await $({
+  verbose: true,
+})`megaera --schema .github/schema.graphql ${await glob('src/**/*.graphql')}`
 await $`prettier --write ${await glob('src/**/*.graphql.ts')}`
 echo(chalk.green('TypeScript files generated successfully.'))
src/context.ts
@@ -0,0 +1,137 @@
+import minimist from 'minimist'
+import { Octokit } from 'octokit'
+import { retry } from '@octokit/plugin-retry'
+import { throttling } from '@octokit/plugin-throttling'
+import path from 'node:path'
+import fs from 'node:fs'
+
+const CWD = process.cwd()
+const GIT_NAME = 'My Badges'
+const GIT_EMAIL = 'my-badges@users.noreply.github.com'
+const GIT_DIR = 'repo'
+const DATA_DIR = 'data'
+const BADGES_DIR = 'my-badges'
+const BADGES_DATAFILE = 'my-badges.json'
+
+export type Context = {
+  cwd: string
+  dataDir: string // used for data gathering
+  dataFile: string
+  dataTasks: string
+  gitDir: string // used for git operations
+  gitName: string
+  gitEmail: string
+  ghRepoOwner: string
+  ghRepoName: string
+  ghUser: string
+  ghToken: string
+  octokit: Octokit
+
+  dryrun: boolean
+  badgesCompact: boolean
+  badgesDatafile: string
+  badgesDir: string // badges output directory (relative to gitDir)
+  badgesSize: string | number
+  badgesPick: string[]
+  badgesOmit: string[]
+  taskName?: string
+  taskSkip: string
+  taskParams?: string
+}
+
+export function createCtx(
+  args: string[] = process.argv.slice(2),
+  env: Record<string, string | undefined> = process.env,
+): Context {
+  const argv = minimist(args, {
+    string: ['data', 'repo', 'token', 'size', 'user', 'pick', 'omit'],
+    boolean: ['dryrun', 'compact'],
+  })
+  const {
+    cwd: _cwd = CWD,
+    token: ghToken = env.GITHUB_TOKEN,
+    repo = env.GITHUB_REPO,
+    user: ghUser = argv._[0] || env.GITHUB_USER,
+    data,
+    size: badgesSize,
+    dryrun,
+    pick,
+    omit,
+    compact: badgesCompact,
+    task: taskName,
+    params: taskParams,
+    'skip-task': taskSkip = '',
+  } = argv
+  const octokit = getOctokit(ghToken)
+  const cwd = path.resolve(_cwd)
+  const dataDir = path.resolve(cwd, DATA_DIR)
+  const dataFile = path.resolve(dataDir, data || `${ghUser}.json`)
+  const dataTasks = path.resolve(dataDir, `${ghUser}.tasks.json`)
+  const gitDir = path.resolve(cwd, GIT_DIR)
+  const badgesDir = path.resolve(gitDir, BADGES_DIR)
+  const badgesDatafile = path.resolve(badgesDir, data || BADGES_DATAFILE)
+  const [ghRepoOwner = '', ghRepoName = ''] = repo?.split('/', 2) || [
+    ghUser,
+    ghUser,
+  ]
+  const badgesPick = pick ? pick.split(',') : []
+  const badgesOmit = omit ? omit.split(',') : []
+
+  !fs.existsSync(dataDir) && fs.mkdirSync(dataDir, { recursive: true })
+  !fs.existsSync(gitDir) && fs.mkdirSync(gitDir, { recursive: true })
+
+  return {
+    cwd,
+    octokit,
+    gitName: GIT_NAME,
+    gitEmail: GIT_EMAIL,
+    gitDir,
+    ghUser,
+    ghToken,
+    ghRepoOwner,
+    ghRepoName,
+    dryrun,
+    badgesDir,
+    badgesDatafile,
+    badgesCompact,
+    badgesSize,
+    badgesOmit,
+    badgesPick,
+    dataDir,
+    dataFile,
+    dataTasks,
+    taskName,
+    taskParams,
+    taskSkip,
+  }
+}
+
+const MyOctokit = Octokit.plugin(retry, throttling)
+
+function getOctokit(token: string) {
+  return new MyOctokit({
+    auth: token,
+    log: console,
+    throttle: {
+      onRateLimit: (retryAfter, options: any, octokit, retryCount) => {
+        octokit.log.warn(
+          `Request quota exhausted for request ${options.method} ${options.url}`,
+        )
+        if (retryCount <= 3) {
+          octokit.log.info(`Retrying after ${retryAfter} seconds!`)
+          return true
+        }
+      },
+      onSecondaryRateLimit: (retryAfter, options: any, octokit, retryCount) => {
+        octokit.log.warn(
+          `SecondaryRateLimit detected for request ${options.method} ${options.url}`,
+        )
+        if (retryCount <= 3) {
+          octokit.log.info(`Retrying after ${retryAfter} seconds!`)
+          return true
+        }
+      },
+    },
+    retry: { doNotRetry: ['429'] },
+  })
+}
src/main.ts
@@ -1,125 +1,72 @@
 #!/usr/bin/env node
 
+import fs from 'node:fs'
 import allBadges from '#badges'
-import minimist from 'minimist'
-import { Octokit } from 'octokit'
-import { retry } from '@octokit/plugin-retry'
-import { throttling } from '@octokit/plugin-throttling'
 import { presentBadges } from './present-badges.js'
-import { getUserBadges, gitPush, syncRepo, thereAreChanges } from './repo.js'
+import { getRepo } from './repo.js'
 import { updateBadges } from './update-badges.js'
 import { updateReadme } from './update-readme.js'
 import { processTasks } from './process-tasks.js'
-import fs from 'node:fs'
 import { Data } from './data.js'
+import { createCtx } from './context.js'
+import url from 'node:url'
 
-void (async function main() {
-  try {
-    const { env } = process
-    const argv = minimist(process.argv.slice(2), {
-      string: [
-        'data',
-        'repo',
-        'token',
-        'size',
-        'user',
-        'pick',
-        'omit',
-        'task',
-        'params',
-        'skip-task',
-      ],
-      boolean: ['dryrun', 'compact'],
+isMain() &&
+  main()
+    .then(() => process.exit(0))
+    .catch((err) => {
+      console.error(err)
+      process.exit(1)
     })
-    const {
-      token = env.GITHUB_TOKEN,
-      repo: repository = env.GITHUB_REPO,
-      user: username = argv._[0] || env.GITHUB_USER,
-      data: dataPath = '',
-      size,
-      dryrun,
-      pick,
-      omit,
-      compact,
-      task,
-      params,
-      'skip-task': skipTask,
-    } = argv
-    const [owner, repo]: [string | undefined, string | undefined] =
-      repository?.split('/', 2) || [username, username]
-    const pickBadges = pick ? pick.split(',') : []
-    const omitBadges = omit ? omit.split(',') : []
 
-    const MyOctokit = Octokit.plugin(retry, throttling)
-    const octokit = new MyOctokit({
-      auth: token,
-      log: console,
-      throttle: {
-        onRateLimit: (retryAfter, options: any, octokit, retryCount) => {
-          octokit.log.warn(
-            `Request quota exhausted for request ${options.method} ${options.url}`,
-          )
-          if (retryCount <= 3) {
-            octokit.log.info(`Retrying after ${retryAfter} seconds!`)
-            return true
-          }
-        },
-        onSecondaryRateLimit: (
-          retryAfter,
-          options: any,
-          octokit,
-          retryCount,
-        ) => {
-          octokit.log.warn(
-            `SecondaryRateLimit detected for request ${options.method} ${options.url}`,
-          )
-          if (retryCount <= 3) {
-            octokit.log.info(`Retrying after ${retryAfter} seconds!`)
-            return true
-          }
-        },
-      },
-      retry: { doNotRetry: ['429'] },
-    })
+export async function main(
+  argv: string[] = process.argv.slice(2),
+  env: Record<string, string | undefined> = process.env,
+) {
+  const ctx = createCtx(argv, env)
+  const repo = getRepo(ctx)
+
+  repo.pull()
 
-    if (owner && repo) syncRepo(owner, repo, token)
+  let data: Data
+  if (fs.existsSync(ctx.dataFile)) {
+    data = JSON.parse(fs.readFileSync(ctx.dataFile, 'utf8')) as Data
+  } else {
+    let ok: boolean
+    ;[ok, data] = await processTasks(ctx)
 
-    let data: Data
-    if (dataPath !== '') {
-      data = JSON.parse(fs.readFileSync(dataPath, 'utf8')) as Data
-    } else {
-      let ok: boolean
-      ;[ok, data] = await processTasks(octokit, username, {
-        task,
-        params,
-        skipTask,
-      })
-      if (!ok) {
-        return
-      }
+    if (!ok) {
+      return
     }
+  }
 
-    let userBadges = getUserBadges()
-    userBadges = presentBadges(
-      allBadges.map((m) => m.default),
-      data,
-      userBadges,
-      pickBadges,
-      omitBadges,
-      compact,
-    )
+  let userBadges = repo.getUserBadges()
+  userBadges = presentBadges(
+    allBadges.map((m) => m.default),
+    data,
+    userBadges,
+    ctx.badgesPick,
+    ctx.badgesOmit,
+    ctx.badgesCompact,
+  )
 
-    console.log(JSON.stringify(userBadges, null, 2))
+  console.log(JSON.stringify(userBadges, null, 2))
 
-    if (owner && repo) {
-      updateBadges(userBadges)
-      updateReadme(userBadges, size)
-      if (!dryrun && thereAreChanges()) {
-        gitPush()
-      }
+  if (repo.ready) {
+    updateBadges(userBadges, ctx.badgesDir, ctx.badgesDatafile)
+    updateReadme(userBadges, ctx.badgesSize, ctx.gitDir)
+    if (!ctx.dryrun && repo.hasChanges()) {
+      repo.push()
     }
-  } catch (e) {
-    console.error(e)
-    process.exit(1)
   }
-})()
+}
+
+function isMain(metaurl = import.meta.url, scriptpath = process.argv[1]) {
+  if (metaurl.startsWith('file:')) {
+    const modulePath = url.fileURLToPath(metaurl).replace(/\.\w+$/, '')
+    const mainPath = fs.realpathSync(scriptpath).replace(/\.\w+$/, '')
+    return mainPath === modulePath
+  }
+
+  return false
+}
src/process-tasks.ts
@@ -1,28 +1,37 @@
 import fs from 'node:fs'
-import { Octokit } from 'octokit'
 import { GraphqlResponseError } from '@octokit/graphql'
 import { Data } from './data.js'
 import { TaskName } from './task.js'
 import allTasks from './task/index.js'
+
+import { type Context } from './context.js'
 import { createBatcher } from './batch.js'
 
 const MAX_ATTEMPTS = 3
 
 export async function processTasks(
-  octokit: Octokit,
-  username: string,
-  {
-    task,
-    params,
-    skipTask,
-  }: { task?: string; params?: string; skipTask?: string } = {},
+  ctx: Pick<
+    Context,
+    | 'octokit'
+    | 'ghUser'
+    | 'dataDir'
+    | 'dataFile'
+    | 'dataTasks'
+    | 'taskName'
+    | 'taskSkip'
+    | 'taskParams'
+  >,
 ): Promise<[boolean, Data]> {
-  if (!fs.existsSync('data')) {
-    fs.mkdirSync('data')
-  }
-  const dataPath = `data/${username}.json`
-  const tasksPath = `data/${username}.tasks.json`
-  const skipTasks = new Set(skipTask?.split(',') || [])
+  const {
+    octokit,
+    ghUser: username,
+    dataFile,
+    dataTasks,
+    taskSkip,
+    taskName,
+    taskParams,
+  } = ctx
+  const taskSkipSet = new Set(taskSkip?.split(',') || [])
 
   let data: Data = {
     user: null!,
@@ -34,8 +43,8 @@ export async function processTasks(
     discussionComments: [],
   }
 
-  if (fs.existsSync(dataPath)) {
-    data = JSON.parse(fs.readFileSync(dataPath, 'utf8')) as Data
+  if (fs.existsSync(dataFile)) {
+    data = JSON.parse(fs.readFileSync(dataFile, 'utf8')) as Data
   }
 
   type Todo = {
@@ -53,16 +62,16 @@ export async function processTasks(
     { taskName: 'stars', params: { username }, attempts: 0 },
   ]
 
-  if (task && params) {
+  if (taskName && taskParams) {
     todo = [
       {
-        taskName: task as TaskName,
-        params: Object.fromEntries(new URLSearchParams(params).entries()),
+        taskName: taskName as TaskName,
+        params: Object.fromEntries(new URLSearchParams(taskParams).entries()),
         attempts: 0,
       },
     ]
-  } else if (fs.existsSync(tasksPath)) {
-    const savedTodo = JSON.parse(fs.readFileSync(tasksPath, 'utf8')) as Todo[]
+  } else if (fs.existsSync(dataTasks)) {
+    const savedTodo = JSON.parse(fs.readFileSync(dataTasks, 'utf8')) as Todo[]
     if (savedTodo.length > 0) {
       todo = savedTodo
     }
@@ -70,7 +79,7 @@ export async function processTasks(
 
   while (todo.length > 0) {
     const { taskName, params, attempts } = todo.shift()!
-    if (skipTasks.has(taskName)) {
+    if (taskSkipSet.has(taskName)) {
       console.log(`Skipping task ${taskName}`)
       continue
     }
@@ -120,8 +129,8 @@ export async function processTasks(
 
     flush()
 
-    fs.writeFileSync(dataPath, JSON.stringify(data, null, 2))
-    fs.writeFileSync(tasksPath, JSON.stringify(todo, null, 2))
+    fs.writeFileSync(dataFile, JSON.stringify(data, null, 2))
+    fs.writeFileSync(dataTasks, JSON.stringify(todo, null, 2))
   }
 
   return [true, data]
src/repo.ts
@@ -1,56 +1,60 @@
 import fs from 'node:fs'
-import { chdir } from 'node:process'
+import path from 'node:path'
 import { Badge } from './badges.js'
-import { exec, execWithOutput } from './utils.js'
-
-export function syncRepo(owner: string, repo: string, token: string) {
-  if (fs.existsSync('repo')) {
-    chdir('repo')
-    exec('git', ['pull'])
-    chdir('..')
-    return
-  }
-
-  exec('git', [
-    'clone',
-    '--depth=1',
-    `https://${owner}:${token}@github.com/${owner}/${repo}.git`,
-    'repo',
-  ])
-
-  chdir('repo')
-
-  exec('git', ['config', 'user.name', 'My Badges'])
-  exec('git', ['config', 'user.email', 'my-badges@users.noreply.github.com'])
-
-  chdir('..')
-}
-
-export function thereAreChanges(): boolean {
-  chdir('repo')
-
-  const changes = execWithOutput('git', ['status', '--porcelain']).trim()
-
-  chdir('..')
-
-  return changes !== ''
-}
-
-export function gitPush() {
-  chdir('repo')
-
-  exec('git', ['add', '.'])
-  exec('git', ['status'])
-  exec('git', ['commit', '-m', 'Update badges'])
-  exec('git', ['push'])
-
-  chdir('..')
-}
-
-export function getUserBadges(): Badge[] {
-  if (!fs.existsSync('repo/my-badges/my-badges.json')) {
-    return []
+import { $ as _$ } from './utils.js'
+import { Context } from './context.js'
+
+export function getRepo({
+  gitDir,
+  ghToken,
+  ghRepoOwner,
+  ghRepoName,
+  gitName,
+  gitEmail,
+  badgesDatafile,
+}: Context) {
+  let ready = false
+
+  const cwd = gitDir
+  const dryrun = !ghRepoOwner || !ghRepoName
+  const basicAuth = ghToken ? `${ghRepoOwner}:${ghToken}@` : ''
+  const gitUrl = `https://${basicAuth}github.com/${ghRepoOwner}/${ghRepoName}.git`
+  const $ = _$({
+    cwd,
+    sync: true,
+  })
+  return {
+    get ready() {
+      return ready
+    },
+    pull() {
+      if (dryrun) return
+      if (fs.existsSync(path.resolve(cwd, '.git'))) {
+        $`git pull`
+        return
+      }
+
+      $`git clone --depth=1 ${gitUrl} .`
+      $`git config user.name ${gitName}`
+      $`git config user.email ${gitEmail}`
+      ready = true
+    },
+    push() {
+      if (!ready) return
+      $`git add .`
+      $`git status`
+      $`git commit -m 'Update badges'`
+      $`git push`
+    },
+    hasChanges(): boolean {
+      if (!ready) return false
+      return $`git status --porcelain`.stdout.trim() !== ''
+    },
+    getUserBadges(): Badge[] {
+      if (!fs.existsSync(badgesDatafile)) return []
+
+      const data = fs.readFileSync(badgesDatafile, 'utf8')
+      return JSON.parse(data)
+    },
   }
-  const data = fs.readFileSync('repo/my-badges/my-badges.json', 'utf8')
-  return JSON.parse(data)
 }
src/update-badges.ts
@@ -1,17 +1,18 @@
 import fs from 'node:fs'
-import { chdir } from 'node:process'
+import path from 'node:path'
 import { Badge } from './badges.js'
 import { quoteAttr } from './utils.js'
 
-export function updateBadges(badges: Badge[]) {
-  chdir('repo')
-
-  fs.mkdirSync('my-badges', { recursive: true })
-  fs.writeFileSync('my-badges/my-badges.json', JSON.stringify(badges, null, 2))
+export function updateBadges(
+  badges: Badge[],
+  badgesDir: string,
+  badgesDatafile: string,
+) {
+  fs.mkdirSync(badgesDir, { recursive: true })
+  fs.writeFileSync(badgesDatafile, JSON.stringify(badges, null, 2))
 
   for (const badge of badges) {
-    const badgePath = `my-badges/${badge.id}.md`
-
+    const badgePath = path.resolve(badgesDir, `${badge.id}.md`)
     const desc = quoteAttr(badge.desc)
     const content =
       `<img src="${badge.image}" alt="${desc}" title="${desc}" width="128">\n` +
@@ -23,6 +24,4 @@ export function updateBadges(badges: Badge[]) {
 
     fs.writeFileSync(badgePath, content)
   }
-
-  chdir('..')
 }
src/update-readme.ts
@@ -1,24 +1,27 @@
 import fs from 'node:fs'
-import { chdir } from 'node:process'
+import path from 'node:path'
 import { Badge } from './badges.js'
 import { quoteAttr } from './utils.js'
 
-export function updateReadme(badges: Badge[], size: number | string) {
-  chdir('repo')
-
-  const readmeFilename = detectReadmeFilename()
+export function updateReadme(
+  badges: Badge[],
+  size: number | string,
+  repoDir: string,
+) {
+  const readmeFilename = detectReadmeFilename(repoDir)
   const readmeContent = fs.readFileSync(readmeFilename, 'utf8')
 
   const content = generateReadme(readmeContent, badges, size)
   fs.writeFileSync(readmeFilename, content)
-
-  chdir('..')
 }
 
-function detectReadmeFilename(): string {
-  if (fs.existsSync('README.md')) return 'README.md'
-  if (fs.existsSync('readme.md')) return 'readme.md'
-  throw new Error('Cannot find README.md')
+function detectReadmeFilename(cwd: string): string {
+  const file = ['README.md', 'readme.md']
+    .map((f) => path.resolve(cwd, f))
+    .find((f) => fs.existsSync(f))
+  if (!file) throw new Error('Cannot find README.md')
+
+  return file
 }
 
 export function generateReadme(
src/utils.ts
@@ -5,6 +5,8 @@ import { Commit } from './task/commits/commits.graphql.js'
 import { PullRequest } from './task/pulls/pulls.graphql.js'
 import { Issue } from './task/issues/issues.graphql.js'
 
+export { $, type TShellSync } from 'zurk'
+
 export function query<T extends Query>(
   octokit: Octokit,
   query: T,
test/main.test.ts
@@ -0,0 +1,16 @@
+import { describe, it, expect } from 'vitest'
+import { main } from '../src/main.js'
+import os from 'node:os'
+
+const temp = `${os.tmpdir()}/${Math.random().toString(36).slice(2)}`
+
+describe.skip('main', () => {
+  console.log('temp', temp)
+  it(
+    'generates badges by repo name',
+    async () => {
+      await main(['--user', 'semrel-extra-bot', '--cwd', temp])
+    },
+    15 * 60 * 1000,
+  )
+})
src/present-badges.test.ts → test/present-badges.test.ts
@@ -1,7 +1,7 @@
 import { describe, test, expect } from 'vitest'
-import { presentBadges } from './present-badges.js'
-import { Badge, define } from './badges.js'
-import { Data } from './data.js'
+import { presentBadges } from '../src/present-badges.js'
+import { Badge, define } from '../src/badges.js'
+import { Data } from '../src/data.js'
 
 describe('present-badges', () => {
   const data: Data = {
@@ -38,7 +38,7 @@ describe('present-badges', () => {
 
   test('presentBadges() applies `pick`', async () => {
     const userBadges = presentBadges(
-      [await import('#badges/stars/stars.js')].map((m) => m.default),
+      [await import('../badges/stars/stars.js')].map((m) => m.default),
       data,
       [],
       ['stars-100', 'stars-500'],
@@ -76,7 +76,7 @@ describe('present-badges', () => {
 
   test('presentBadges() applies `omit`', async () => {
     const userBadges = presentBadges(
-      [await import('#badges/stars/stars.js')].map((m) => m.default),
+      [await import('../badges/stars/stars.js')].map((m) => m.default),
       data,
       [],
       ['stars-100', 'stars-500'],
@@ -102,7 +102,7 @@ describe('present-badges', () => {
 
   test('presentBadges() supports masks for `omit` && `pick`', async () => {
     const userBadges = presentBadges(
-      [await import('#badges/stars/stars.js')].map((m) => m.default),
+      [await import('../badges/stars/stars.js')].map((m) => m.default),
       data,
       [],
       ['stars-*'],
@@ -140,7 +140,7 @@ describe('present-badges', () => {
 
   test('presentBadges() applies `compact`', async () => {
     const userBadges = presentBadges(
-      [await import('#badges/stars/stars.js')].map((m) => m.default),
+      [await import('../badges/stars/stars.js')].map((m) => m.default),
       data,
       [],
       ['stars-1000', 'stars-2000', 'stars-5000'],
test/repo.test.ts
@@ -0,0 +1,25 @@
+import path from 'node:path'
+import fs from 'node:fs'
+import os from 'node:os'
+import { describe, it, expect, afterAll } from 'vitest'
+import { getRepo } from '../src/repo.js'
+import { createCtx } from '../src/context.js'
+
+const temp = `${os.tmpdir()}/${Math.random().toString(36).slice(2)}`
+
+describe('repo API', () => {
+  const ctx = createCtx(['--repo', 'webpod/zurk', '--cwd', temp])
+  const repo = getRepo(ctx)
+
+  describe('syncRepo()', () => {
+    it('should sync the repo', () => {
+      repo.pull()
+      const pkgJson = JSON.parse(
+        fs.readFileSync(path.resolve(temp, 'repo/package.json'), 'utf-8'),
+      )
+      expect(pkgJson.name).toEqual('zurk')
+    })
+  })
+})
+
+afterAll(() => fs.rmdirSync(temp, { recursive: true }))
src/update-readme.test.ts → test/update-readme.test.ts
@@ -1,8 +1,8 @@
 import { describe, test, expect } from 'vitest'
-import { generateReadme } from './update-readme.js'
-import type { Badge } from './badges.js'
-import abcPresenter from '#badges/abc-commit/abc-commit.js'
-import { badgeCollection } from './present-badges.js'
+import { generateReadme } from '../src/update-readme.js'
+import type { Badge } from '../src/badges.js'
+import abcPresenter from '../badges/abc-commit/abc-commit.js'
+import { badgeCollection } from '../src/present-badges.js'
 
 describe('generateReadme()', () => {
   test('injects badges to md contents', () => {
package-lock.json
@@ -14,7 +14,8 @@
         "@octokit/plugin-throttling": "^9.3.2",
         "megaera": "^1.0.2",
         "minimist": "^1.2.8",
-        "octokit": "^4.0.2"
+        "octokit": "^4.0.2",
+        "zurk": "^0.6.3"
       },
       "bin": {
         "update-my-badges": "dist/src/main.js"
@@ -1533,12 +1534,13 @@
       }
     },
     "node_modules/braces": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
-      "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+      "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
-        "fill-range": "^7.0.1"
+        "fill-range": "^7.1.1"
       },
       "engines": {
         "node": ">=8"
@@ -1702,9 +1704,9 @@
       "dev": true
     },
     "node_modules/cross-spawn": {
-      "version": "7.0.3",
-      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
-      "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -1880,10 +1882,11 @@
       }
     },
     "node_modules/fill-range": {
-      "version": "7.0.1",
-      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
-      "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+      "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
         "to-regex-range": "^5.0.1"
       },
@@ -2085,6 +2088,7 @@
       "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
       "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
       "dev": true,
+      "license": "MIT",
       "engines": {
         "node": ">=0.12.0"
       }
@@ -2235,12 +2239,13 @@
       }
     },
     "node_modules/micromatch": {
-      "version": "4.0.5",
-      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
-      "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+      "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
-        "braces": "^3.0.2",
+        "braces": "^3.0.3",
         "picomatch": "^2.3.1"
       },
       "engines": {
@@ -2808,6 +2813,7 @@
       "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
       "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
         "is-number": "^7.0.0"
       },
@@ -3182,6 +3188,12 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/zurk": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/zurk/-/zurk-0.6.3.tgz",
+      "integrity": "sha512-1EyIiD9td75xMmmZv9kD1YXOtG2vPxXZFqXWdgFd2Fu55OLWLgfmvFqaQKOKotpbXeQ6JpsAsaPmU4By9GEnoA==",
+      "license": "MIT"
+    },
     "node_modules/zx": {
       "version": "8.2.4",
       "resolved": "https://registry.npmjs.org/zx/-/zx-8.2.4.tgz",
package.json
@@ -28,7 +28,8 @@
     "@octokit/plugin-throttling": "^9.3.2",
     "megaera": "^1.0.2",
     "minimist": "^1.2.8",
-    "octokit": "^4.0.2"
+    "octokit": "^4.0.2",
+    "zurk": "^0.6.3"
   },
   "devDependencies": {
     "@octokit/graphql-schema": "^15.25.0",