Commit 76ba1ca
Changed files (7)
src
test
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)
+ })
+})