Commit 76ba1ca

Anton Golub <antongolub@antongolub.com>
2023-10-13 23:20:29
refactor: separate `get-badges` module
1 parent 9d4d77d
src/collect/collect.ts
@@ -24,8 +24,8 @@ export type Pull = PullsQuery['user']['pullRequests']['nodes'][0]
 export type Issues = IssuesQuery['user']['issues']['nodes'][0]
 
 export async function collect(
-  username: string,
   octokit: Octokit,
+  username: string,
 ): Promise<Data> {
   const { user } = await octokit.graphql<UserQuery>(
     loadGraphql('./user.graphql'),
src/constants.ts
@@ -0,0 +1,1 @@
+export const MY_BADGES_JSON_PATH = 'my-badges/my-badges.json'
src/get-badges.ts
@@ -0,0 +1,102 @@
+import { collect, Data } from './collect/collect.js'
+import fs from 'node:fs'
+import { Badge } from './badges.js'
+import { Octokit, RequestError } from 'octokit'
+import { decodeBase64 } from './utils.js'
+import { MY_BADGES_JSON_PATH } from './constants.js'
+
+export const getData = async (
+  octokit: Octokit,
+  dataPath: string,
+  username: string,
+): Promise<Data> => {
+  if (dataPath !== '') {
+    if (!fs.existsSync(dataPath)) {
+      throw new Error('Data file not found')
+    }
+    return JSON.parse(fs.readFileSync(dataPath, 'utf8')) as Data
+  }
+
+  if (!username) {
+    throw new Error('Specify username')
+  }
+
+  const data = await collect(octokit, username)
+  if (!fs.existsSync('data')) {
+    fs.mkdirSync('data')
+  }
+  fs.writeFileSync(`data/${username}.json`, JSON.stringify(data, null, 2))
+
+  return data
+}
+
+export const getOldData = async (
+  octokit: Octokit,
+  owner: string,
+  repo: string,
+  myBadges?: any, // test snapshot
+) => {
+  console.log('Loading my-badges.json')
+
+  const { data } =
+    myBadges ||
+    (await octokit.request<'content-file'>(
+      'GET /repos/{owner}/{repo}/contents/{path}',
+      {
+        owner,
+        repo,
+        path: MY_BADGES_JSON_PATH,
+      },
+    ))
+
+  try {
+    const oldJson = decodeBase64(data.content)
+    const jsonSha = data.sha
+    const userBadges = JSON.parse(oldJson)
+
+    // Add missing tier property in old my-badges.json.
+    for (const b of userBadges) {
+      if (b.tier === undefined) b.tier = 0
+    }
+
+    return {
+      userBadges,
+      jsonSha,
+      oldJson,
+    }
+  } catch (err) {
+    console.log(err)
+    if (err instanceof RequestError && err.response?.status != 404) {
+      throw err
+    }
+  }
+
+  return {}
+}
+
+export const getBadges = async (
+  octokit: Octokit,
+  dataPath: string,
+  username: string,
+  owner: string,
+  repo: string,
+): Promise<{
+  data: Data
+  userBadges: Badge[]
+  oldJson?: string
+  jsonSha?: string
+}> => {
+  const data = await getData(octokit, dataPath, username)
+  const {
+    oldJson = undefined,
+    jsonSha = undefined,
+    userBadges = [],
+  } = owner && repo ? await getOldData(octokit, owner, repo) : {}
+
+  return {
+    data,
+    oldJson,
+    jsonSha,
+    userBadges,
+  }
+}
src/main.ts
@@ -1,124 +1,91 @@
 #!/usr/bin/env node
 
-import fs from 'node:fs'
 import minimist from 'minimist'
-import { Octokit, RequestError } from 'octokit'
+import { Octokit } from 'octokit'
 import { retry } from '@octokit/plugin-retry'
 import { throttling } from '@octokit/plugin-throttling'
-import { collect, Data } from './collect/collect.js'
-import { Badge } from './badges.js'
 import { updateReadme } from './update-readme.js'
 import { updateBadges } from './update-badges.js'
 import { presentBadges } from './present-badges.js'
+import { getBadges } from './get-badges.js'
 
 void (async function main() {
-  const { env } = process
-  const argv = minimist(process.argv.slice(2), {
-    string: ['data', 'repo', 'token', 'size', 'user', 'pick', 'omit'],
-    boolean: ['dryrun', 'compact'],
-  })
-  const {
-    token = env.GITHUB_TOKEN,
-    repo: repository = env.GITHUB_REPO,
-    user: username = argv._[0] || env.GITHUB_USER,
-    data: dataPath = '',
-    size,
-    dryrun,
-    pick,
-    omit,
-    compact,
-  } = argv
-  const [owner, repo] = 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) => {
-        octokit.log.warn(
-          `SecondaryRateLimit detected for request ${options.method} ${options.url}`,
-        )
-      },
-    },
-    retry: { doNotRetry: ['429'] },
-  })
-
-  let data: Data
-  if (dataPath !== '') {
-    if (!fs.existsSync(dataPath)) {
-      console.error('Data file not found')
-      process.exit(1)
-    }
-    data = JSON.parse(fs.readFileSync(dataPath, 'utf8'))
-  } else {
-    if (!username) {
-      console.error('Specify username')
-      process.exit(1)
-    }
-    data = await collect(username, octokit)
-    if (!fs.existsSync('data')) {
-      fs.mkdirSync('data')
-    }
-    fs.writeFileSync(`data/${username}.json`, JSON.stringify(data, null, 2))
-  }
+  try {
+    const { env } = process
+    const argv = minimist(process.argv.slice(2), {
+      string: ['data', 'repo', 'token', 'size', 'user', 'pick', 'omit'],
+      boolean: ['dryrun', 'compact'],
+    })
+    const {
+      token = env.GITHUB_TOKEN,
+      repo: repository = env.GITHUB_REPO,
+      user: username = argv._[0] || env.GITHUB_USER,
+      data: dataPath = '',
+      size,
+      dryrun,
+      pick,
+      omit,
+      compact,
+    } = argv
+    const [owner, repo] = repository?.split('/', 2) || [username, username]
+    const pickBadges = pick ? pick.split(',') : []
+    const omitBadges = omit ? omit.split(',') : []
 
-  let userBadges: Badge[] = []
-  let oldJson: string | undefined
-  let jsonSha: string | undefined
-  if (owner && repo) {
-    console.log('Loading my-badges.json')
-    try {
-      const myBadgesResp = await octokit.request<'content-file'>(
-        'GET /repos/{owner}/{repo}/contents/{path}',
-        {
-          owner,
-          repo,
-          path: 'my-badges/my-badges.json',
+    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
+          }
         },
-      )
-      oldJson = Buffer.from(myBadgesResp.data.content, 'base64').toString(
-        'utf8',
-      )
-      jsonSha = myBadgesResp.data.sha
-      userBadges = JSON.parse(oldJson)
-
-      // Add missing tier property in old my-badges.json.
-      for (const b of userBadges) {
-        if (b.tier === undefined) b.tier = 0
-      }
-    } catch (err) {
-      if (err instanceof RequestError && err.response?.status != 404) {
-        throw err
-      }
-    }
-  }
-
-  userBadges = presentBadges(data, userBadges, pickBadges, omitBadges, compact)
-
-  console.log(JSON.stringify(userBadges, null, 2))
+        onSecondaryRateLimit: (retryAfter, options: any, octokit) => {
+          octokit.log.warn(
+            `SecondaryRateLimit detected for request ${options.method} ${options.url}`,
+          )
+        },
+      },
+      retry: { doNotRetry: ['429'] },
+    })
 
-  if (owner && repo) {
-    await updateBadges(
+    let { userBadges, data, oldJson, jsonSha } = await getBadges(
       octokit,
+      dataPath,
+      username,
       owner,
       repo,
+    )
+
+    userBadges = presentBadges(
+      data,
       userBadges,
-      oldJson,
-      jsonSha,
-      dryrun,
+      pickBadges,
+      omitBadges,
+      compact,
     )
-    await updateReadme(octokit, owner, repo, userBadges, size, dryrun)
+
+    console.log(JSON.stringify(userBadges, null, 2))
+
+    if (owner && repo) {
+      await updateBadges(
+        octokit,
+        owner,
+        repo,
+        userBadges,
+        oldJson,
+        jsonSha,
+        dryrun,
+      )
+      await updateReadme(octokit, owner, repo, userBadges, size, dryrun)
+    }
+  } catch (e) {
+    console.error(e)
+    process.exit(1)
   }
 })()
src/update-badges.ts
@@ -1,6 +1,7 @@
 import { Octokit, RequestError } from 'octokit'
 import { Badge } from './badges.js'
 import { quoteAttr, upload } from './utils.js'
+import { MY_BADGES_JSON_PATH } from './constants.js'
 
 export async function updateBadges(
   octokit: Octokit,
@@ -11,8 +12,6 @@ export async function updateBadges(
   jsonSha: string | undefined,
   dryrun: boolean,
 ) {
-  const myBadgesPath = 'my-badges/my-badges.json'
-
   const newJson = JSON.stringify(badges, null, 2)
   if (newJson == oldJson) {
     console.log('No change in my-badges.json')
@@ -23,7 +22,7 @@ export async function updateBadges(
       {
         owner,
         repo,
-        path: myBadgesPath,
+        path: MY_BADGES_JSON_PATH,
         message: 'Update my-badges',
         committer: {
           name: 'My Badges',
src/utils.ts
@@ -27,6 +27,11 @@ export function quoteAttr(s: string) {
 
 export const expectType = <T>(expression: T) => void 0
 
+export const decodeBase64 = (data: string) =>
+  Buffer.from(data, 'base64').toString('utf8')
+export const encodeBase64 = (data: string) =>
+  Buffer.from(data, 'utf8').toString('base64')
+
 export const upload = async (
   octokit: Octokit,
   route: Parameters<Octokit['request']>[0],
@@ -46,6 +51,6 @@ export const upload = async (
   console.log(`Uploading ${data?.path}`)
   return octokit.request(route, {
     ...data,
-    content: Buffer.from(data?.content as string, 'utf8').toString('base64'),
+    content: encodeBase64(data?.content as string),
   })
 }
test/get-badges.test.ts
@@ -0,0 +1,97 @@
+import * as assert from 'node:assert'
+import { describe, it } from 'node:test'
+import fs from 'node:fs'
+import path from 'node:path'
+import os from 'node:os'
+import { Octokit } from 'octokit'
+import { getBadges, getOldData } from '../src/get-badges.js'
+import { encodeBase64 } from '../src/utils.js'
+
+const tempy = () => fs.mkdtempSync(path.join(os.tmpdir(), 'tempy-'))
+
+describe('get-badges', () => {
+  const octokit = new Octokit({})
+  const username = 'antongolub'
+  const owner = username
+  const repo = username
+
+  it('getBadges() reads snapshot data if dataPath specified', async () => {
+    const dataPath = path.join(tempy(), 'data.json')
+    const _data = {
+      user: {
+        id: 'MDQ6VXNlcjUyODgwNDY=',
+        login: 'antongolub',
+        name: 'Anton Golub',
+        createdAt: '2013-08-22T15:51:42Z',
+        starredRepositories: {
+          totalCount: 161,
+        },
+      },
+      repos: [],
+    }
+    fs.writeFileSync(dataPath, JSON.stringify(_data), 'utf-8')
+
+    const { data, userBadges, jsonSha, oldJson } = await getBadges(
+      octokit,
+      dataPath,
+      username,
+      owner,
+      repo,
+    )
+
+    assert.deepEqual(_data, data)
+    // assert.deepEqual(userBadges, [])
+    // assert.equal(jsonSha, undefined)
+    // assert.equal(oldJson, undefined)
+  })
+
+  it('getBadges() throws an err if `dataPath` is unreachable', async () => {
+    try {
+      const dataPath = '/foo/bar/baz'
+      await getBadges(octokit, dataPath, username, owner, repo)
+    } catch (e) {
+      assert.equal((e as Error).message, 'Data file not found')
+    }
+  })
+
+  it('getBadges() throws an err if `username` is empty', async () => {
+    try {
+      const dataPath = ''
+      const username = ''
+      await getBadges(octokit, dataPath, username, owner, repo)
+    } catch (e) {
+      assert.equal((e as Error).message, 'Specify username')
+    }
+  })
+
+  it('getOldData() returns and processes `my-badges.json` data from the remote', async () => {
+    const myBadges = [
+      {
+        id: 'ab-commit',
+        tier: 2,
+        desc: 'One of my commit sha starts with "ab".',
+        body: '- <a href="https://github.com/semrel-extra/demo-msr-cicd/commit/ab866aea0e5fad02bb2a8d11f753821de13ee78f"><strong>ab</strong>866aea0e5fad02bb2a8d11f753821de13ee78f</a>',
+        image:
+          'https://github.com/my-badges/my-badges/blob/master/src/all-badges/abc-commit/ab-commit.png?raw=true',
+      },
+      {
+        id: 'stars-1000',
+        tier: 3,
+        desc: 'I collected 1000 stars.',
+        body: 'Repos:\n\n* <a href="https://github.com/imsnif/synp">imsnif/synp: ★710</a>\n* <a href="https://github.com/dhoulb/multi-semantic-release">dhoulb/multi-semantic-release: ★187</a>\n* <a href="https://github.com/antongolub/yarn-audit-fix">antongolub/yarn-audit-fix: ★166</a>\n* <a href="https://github.com/qiwi/multi-semantic-release">qiwi/multi-semantic-release: ★83</a>\n* <a href="https://github.com/antongolub/tsc-esm-fix">antongolub/tsc-esm-fix: ★56</a>\n* <a href="https://github.com/antongolub/npm-registry-firewall">antongolub/npm-registry-firewall: ★50</a>\n* <a href="https://github.com/semrel-extra/zx-semrel">semrel-extra/zx-semrel: ★46</a>\n* <a href="https://github.com/antongolub/action-setup-bun">antongolub/action-setup-bun: ★45</a>\n* <a href="https://github.com/qiwi/pijma">qiwi/pijma: ★31</a>\n* <a href="https://github.com/qiwi/semantic-release-gh-pages-plugin">qiwi/semantic-release-gh-pages-plugin: ★21</a>\n* <a href="https://github.com/qiwi/mixin">qiwi/mixin: ★15</a>\n* <a href="https://github.com/qiwi/nestjs-enterprise">qiwi/nestjs-enterprise: ★14</a>\n* <a href="https://github.com/qiwi/json-rpc">qiwi/json-rpc: ★12</a>\n* <a href="https://github.com/qiwi/substrate">qiwi/substrate: ★8</a>\n* <a href="https://github.com/qiwi/decorator-utils">qiwi/decorator-utils: ★8</a>\n* <a href="https://github.com/antongolub/reqresnext">antongolub/reqresnext: ★7</a>\n* <a href="https://github.com/qiwi/qorsproxy">qiwi/qorsproxy: ★6</a>\n* <a href="https://github.com/qiwi/blank-ts-monorepo">qiwi/blank-ts-monorepo: ★6</a>\n* <a href="https://github.com/antongolub/npm-upgrade-monorepo">antongolub/npm-upgrade-monorepo: ★6</a>\n* <a href="https://github.com/antongolub/nestjs-esm-fix">antongolub/nestjs-esm-fix: ★6</a>\n* <a href="https://github.com/qiwi/blank-ts-repo">qiwi/blank-ts-repo: ★5</a>\n* <a href="https://github.com/qiwi/QiwiButtons">qiwi/QiwiButtons: ★4</a>\n* <a href="https://github.com/qiwi/primitive-storage">qiwi/primitive-storage: ★4</a>\n* <a href="https://github.com/dhoulb/blork">dhoulb/blork: ★4</a>\n* <a href="https://github.com/qiwi/protopipe">qiwi/protopipe: ★3</a>\n* <a href="https://github.com/qiwi/mware">qiwi/mware: ★3</a>\n* <a href="https://github.com/antongolub/git-glob-cp">antongolub/git-glob-cp: ★3</a>\n* <a href="https://github.com/antongolub/demo-action-setup-bun">antongolub/demo-action-setup-bun: ★3</a>\n* <a href="https://github.com/qiwi-forks/esm">qiwi-forks/esm: ★2</a>\n* <a href="https://github.com/qiwi/health-indicator">qiwi/health-indicator: ★2</a>\n* <a href="https://github.com/antongolub/push-it-to-the-limit">antongolub/push-it-to-the-limit: ★2</a>\n* <a href="https://github.com/antongolub/lockfile">antongolub/lockfile: ★2</a>\n* <a href="https://github.com/antongolub/blank-ts">antongolub/blank-ts: ★2</a>\n* <a href="https://github.com/qiwi-forks/dts-bundle">qiwi-forks/dts-bundle: ★1</a>\n* <a href="https://github.com/qiwi/thromise">qiwi/thromise: ★1</a>\n* <a href="https://github.com/qiwi/stdstream-snapshot">qiwi/stdstream-snapshot: ★1</a>\n* <a href="https://github.com/qiwi/queuefy">qiwi/queuefy: ★1</a>\n* <a href="https://github.com/qiwi/logwrap">qiwi/logwrap: ★1</a>\n* <a href="https://github.com/qiwi/ldap">qiwi/ldap: ★1</a>\n* <a href="https://github.com/qiwi/inside-out-promise">qiwi/inside-out-promise: ★1</a>\n* <a href="https://github.com/qiwi/common-formatters">qiwi/common-formatters: ★1</a>\n* <a href="https://github.com/qiwi/card-info">qiwi/card-info: ★1</a>\n* <a href="https://github.com/antongolub/repeater">antongolub/repeater: ★1</a>\n* <a href="https://github.com/antongolub/flow-remove-types-recursive">antongolub/flow-remove-types-recursive: ★1</a>\n* <a href="https://github.com/antongolub/akshenz">antongolub/akshenz: ★1</a>\n* <a href="https://github.com/antongolub/abstractest">antongolub/abstractest: ★1</a>\n\n<sup>I have push, maintainer or admin permissions, so I\'m definitely an author.<sup>\n',
+        image:
+          'https://github.com/my-badges/my-badges/blob/master/src/all-badges/stars/stars-1000.png?raw=true',
+      },
+    ]
+    const mockedRes = {
+      data: {
+        content: encodeBase64(JSON.stringify(myBadges)),
+        sha: 'sha',
+      },
+    }
+    const res = await getOldData(octokit, owner, repo, mockedRes)
+
+    assert.equal(res.jsonSha, mockedRes.data.sha)
+    assert.deepEqual(res.userBadges, myBadges)
+  })
+})