From 3a47533522b89ba67f9fadbe8abe5bc9dbfe2d04 Mon Sep 17 00:00:00 2001 From: lonewolfyx Date: Wed, 26 Aug 2026 23:16:51 +0800 Subject: [PATCH] feat(action): add GitHub PR stats action with SVG generation - Add @actions/core and @actions/github dependencies for GitHub Actions SDK - Create action.yml defining inputs, outputs and composite run steps - Implement pull request aggregation logic in src/stats/aggregate.ts - Build GitHub API client wrapper in src/github/client.ts - Add configuration parsing from action inputs in src/config.ts - Create main workflow in src/index.ts orchestrating PR collection and SVG generation - Implement pull request fetching with search API and pagination in src/github/pulls.ts - Build SVG renderer with responsive layout in src/svg/render.ts - Integrate repository star counting and metadata hydration - Add retry mechanism for GitHub API calls - Support configurable output path and max rows limit - Provide detailed console logging and error handling --- action.yml | 69 +++++++ package.json | 4 + pnpm-lock.yaml | 195 ++++++++++++++++++ src/config.ts | 60 ++++++ src/github/client.ts | 9 + src/github/pulls.ts | 408 +++++++++++++++++++++++++++++++++++++ src/github/repositories.ts | 146 +++++++++++++ src/github/retry.ts | 221 ++++++++++++++++++++ src/index.ts | 104 ++++++++++ src/stats/aggregate.ts | 76 +++++++ src/svg/render.ts | 390 +++++++++++++++++++++++++++++++++++ src/types.ts | 20 ++ 12 files changed, 1702 insertions(+) create mode 100644 action.yml create mode 100644 src/config.ts create mode 100644 src/github/client.ts create mode 100644 src/github/pulls.ts create mode 100644 src/github/repositories.ts create mode 100644 src/github/retry.ts create mode 100644 src/index.ts create mode 100644 src/stats/aggregate.ts create mode 100644 src/svg/render.ts create mode 100644 src/types.ts diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..26b46a0 --- /dev/null +++ b/action.yml @@ -0,0 +1,69 @@ +name: GitHub PR Stats + +description: > + Generate repository-level pull request contribution statistics + for a GitHub user and render them as an SVG table. + +author: your-name + +inputs: + username: + description: GitHub username + required: true + + token: + description: > + GitHub token used to query the API. + Defaults to github.token. A PAT can be supplied when additional + repository access is required. + required: false + default: ${{ github.token }} + + output: + description: SVG output file path + required: false + default: github-pr-stats.svg + + max-rows: + description: > + Maximum number of repositories rendered into the SVG. + Use 0 to render all repositories. + required: false + default: '0' + +outputs: + data: + description: Repository PR statistics as JSON + + svg-path: + description: Generated SVG file path + + repository-count: + description: Number of repositories + + pr-count: + description: Total number of pull requests + +runs: + using: composite + steps: + - uses: actions/checkout@v7 + + - name: Install pnpm + uses: pnpm/action-setup@v6 + + - name: Set node + uses: actions/setup-node@v7 + with: + node-version: lts/* + cache: pnpm + + - name: Install + run: pnpm i + + - name: Generate Github PR Stats + run: tsx ./src/index.ts + +branding: + icon: git-pull-request + color: blue diff --git a/package.json b/package.json index 35c519c..30eefe4 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,10 @@ "lint:fix": "eslint --fix", "prepare": "simple-git-hooks" }, + "dependencies": { + "@actions/core": "^3.0.1", + "@actions/github": "^9.1.1" + }, "devDependencies": { "@antfu/eslint-config": "^9.3.0", "@lonewolfyx/tsconfig": "^0.0.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 086e753..05c7b95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -206,6 +206,13 @@ settings: importers: .: + dependencies: + '@actions/core': + specifier: ^3.0.1 + version: 3.0.1 + '@actions/github': + specifier: ^9.1.1 + version: 9.1.1 devDependencies: '@antfu/eslint-config': specifier: ^9.3.0 @@ -246,6 +253,24 @@ importers: packages: + '@actions/core@3.0.1': + resolution: {integrity: sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==} + + '@actions/exec@3.0.0': + resolution: {integrity: sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==} + + '@actions/github@9.1.1': + resolution: {integrity: sha512-tL5JbYOBZHc0ngEnCsaDcryUizIUIlQyIMwy1Wkx93H5HzbBJ7TbiPx2PnFjBwZW0Vh05JmfFZhecE6gglYegA==} + + '@actions/http-client@3.0.2': + resolution: {integrity: sha512-JP38FYYpyqvUsz+Igqlc/JG6YO9PaKuvqjM3iGvaLqFnJ7TFmcLyy2IDrY0bI0qCQug8E9K+elv5ZNfw62ZJzA==} + + '@actions/http-client@4.0.1': + resolution: {integrity: sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==} + + '@actions/io@3.0.2': + resolution: {integrity: sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==} + '@antfu/eslint-config@9.3.0': resolution: {integrity: sha512-+CIC4G6MowUl1RHiT01Ah9Obo/wwVynw7uNlN8ODwEn2opo9W1KzaatCrmu4WhGGJjOkOkyT5w7kKfQ//agHrQ==} hasBin: true @@ -602,6 +627,54 @@ packages: peerDependencies: typescript: ^5.8.2 + '@octokit/auth-token@6.0.0': + resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} + engines: {node: '>= 20'} + + '@octokit/core@7.0.7': + resolution: {integrity: sha512-DcB0M3KFgr9ECI328lhBMVsyFT2DnmNucSBTqEN3exyNKUzkkpUSCHmTRcunF41Eou2TIQKW4seewri8ON9bSA==} + engines: {node: '>= 20'} + + '@octokit/endpoint@11.0.4': + resolution: {integrity: sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA==} + engines: {node: '>= 20'} + + '@octokit/graphql@9.0.4': + resolution: {integrity: sha512-5s15CCiY8XXQ+FG+b1YQcl6Z2FA++nwAz/tg2VUrTmnMncP+2nnGUEYANImdnxsA2Fnq+Mbl7hDjUTw7cFAwcg==} + engines: {node: '>= 20'} + + '@octokit/openapi-types@27.0.0': + resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} + + '@octokit/openapi-types@28.0.0': + resolution: {integrity: sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==} + + '@octokit/plugin-paginate-rest@14.0.0': + resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-rest-endpoint-methods@17.0.0': + resolution: {integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/request-error@7.1.1': + resolution: {integrity: sha512-+eaY7G2VVpSf2pc5Gn1+mph837V/d/TYTJAgWL9Tb0ogGYcpN3IlAVFgjL+Vv93F/sevrxkvsYCedtpLdcFLzA==} + engines: {node: '>= 20'} + + '@octokit/request@10.0.15': + resolution: {integrity: sha512-3CBg9aJ0hO9Pjyij8LbK/xYtEaPws9SW7xKz67daPNxQB1q5Y9OMA7DDOG0A6Hwf9ygGu3tvzusg0LXQ8/wAjA==} + engines: {node: '>= 20'} + + '@octokit/types@16.0.0': + resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + + '@octokit/types@17.0.0': + resolution: {integrity: sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==} + '@ota-meshi/ast-token-store@0.3.0': resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -1010,6 +1083,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + before-after-hook@4.0.0: + resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -1064,6 +1140,10 @@ packages: confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + content-type@3.0.0: + resolution: {integrity: sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw==} + engines: {node: '>=22'} + convert-hrtime@5.0.0: resolution: {integrity: sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==} engines: {node: '>=12'} @@ -1532,6 +1612,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-with-bigint@3.5.12: + resolution: {integrity: sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w==} + jsonc-eslint-parser@3.3.0: resolution: {integrity: sha512-hYTGkHGNRZnXOFZ1urhINADoqDrGfpy53cjw+dxk84QE0pUDujQzeUeamNs6Mz44/TKD49z2x6/GVSu4ZrtA+Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -2003,6 +2086,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2025,6 +2112,10 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@6.28.0: + resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} + engines: {node: '>=18.17'} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -2040,6 +2131,9 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universal-user-agent@7.0.3: + resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + update-browserslist-db@1.3.1: resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} hasBin: true @@ -2109,6 +2203,37 @@ packages: snapshots: + '@actions/core@3.0.1': + dependencies: + '@actions/exec': 3.0.0 + '@actions/http-client': 4.0.1 + + '@actions/exec@3.0.0': + dependencies: + '@actions/io': 3.0.2 + + '@actions/github@9.1.1': + dependencies: + '@actions/http-client': 3.0.2 + '@octokit/core': 7.0.7 + '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.7) + '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.7) + '@octokit/request': 10.0.15 + '@octokit/request-error': 7.1.1 + undici: 6.28.0 + + '@actions/http-client@3.0.2': + dependencies: + tunnel: 0.0.6 + undici: 6.28.0 + + '@actions/http-client@4.0.1': + dependencies: + tunnel: 0.0.6 + undici: 6.28.0 + + '@actions/io@3.0.2': {} + '@antfu/eslint-config@9.3.0(@typescript-eslint/typescript-estree@8.68.0(typescript@6.0.3))(@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@antfu/install-pkg': 2.0.1 @@ -2382,6 +2507,64 @@ snapshots: dependencies: typescript: 6.0.3 + '@octokit/auth-token@6.0.0': {} + + '@octokit/core@7.0.7': + dependencies: + '@octokit/auth-token': 6.0.0 + '@octokit/graphql': 9.0.4 + '@octokit/request': 10.0.15 + '@octokit/request-error': 7.1.1 + '@octokit/types': 17.0.0 + before-after-hook: 4.0.0 + universal-user-agent: 7.0.3 + + '@octokit/endpoint@11.0.4': + dependencies: + '@octokit/types': 17.0.0 + universal-user-agent: 7.0.3 + + '@octokit/graphql@9.0.4': + dependencies: + '@octokit/request': 10.0.15 + '@octokit/types': 17.0.0 + universal-user-agent: 7.0.3 + + '@octokit/openapi-types@27.0.0': {} + + '@octokit/openapi-types@28.0.0': {} + + '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.7)': + dependencies: + '@octokit/core': 7.0.7 + '@octokit/types': 16.0.0 + + '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.7)': + dependencies: + '@octokit/core': 7.0.7 + '@octokit/types': 16.0.0 + + '@octokit/request-error@7.1.1': + dependencies: + '@octokit/types': 17.0.0 + + '@octokit/request@10.0.15': + dependencies: + '@octokit/endpoint': 11.0.4 + '@octokit/request-error': 7.1.1 + '@octokit/types': 17.0.0 + content-type: 3.0.0 + json-with-bigint: 3.5.12 + universal-user-agent: 7.0.3 + + '@octokit/types@16.0.0': + dependencies: + '@octokit/openapi-types': 27.0.0 + + '@octokit/types@17.0.0': + dependencies: + '@octokit/openapi-types': 28.0.0 + '@ota-meshi/ast-token-store@0.3.0': {} '@oxc-project/types@0.146.0': {} @@ -2708,6 +2891,8 @@ snapshots: baseline-browser-mapping@2.11.19: {} + before-after-hook@4.0.0: {} + boolbase@1.0.0: {} brace-expansion@5.0.9: @@ -2746,6 +2931,8 @@ snapshots: confbox@0.2.4: {} + content-type@3.0.0: {} + convert-hrtime@5.0.0: {} core-js-compat@3.50.0: @@ -3239,6 +3426,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-with-bigint@3.5.12: {} + jsonc-eslint-parser@3.3.0: dependencies: acorn: 8.18.0 @@ -3893,6 +4082,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel@0.0.6: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -3910,6 +4101,8 @@ snapshots: undici-types@8.3.0: {} + undici@6.28.0: {} + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -3934,6 +4127,8 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + universal-user-agent@7.0.3: {} + update-browserslist-db@1.3.1(browserslist@4.28.8): dependencies: browserslist: 4.28.8 diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..15af7eb --- /dev/null +++ b/src/config.ts @@ -0,0 +1,60 @@ +import * as core from '@actions/core' + +export interface ActionConfig { + username: string + token: string + output: string + maxRows: number +} + +function parseNonNegativeInteger( + value: string, + name: string, +): number { + const parsed = Number.parseInt(value, 10) + + if ( + !Number.isInteger(parsed) + || parsed < 0 + ) { + throw new Error( + `"${name}" must be a non-negative integer.`, + ) + } + + return parsed +} + +export function resolveConfig(): ActionConfig { + const username = core + .getInput('username', { required: true }) + .trim() + + const token = core + .getInput('token', { required: true }) + .trim() + + const output = core + .getInput('output') + .trim() || 'github-pr-stats.svg' + + const maxRows = parseNonNegativeInteger( + core.getInput('max-rows') || '0', + 'max-rows', + ) + + if (!username) { + throw new Error('GitHub username is required.') + } + + if (!token) { + throw new Error('GitHub token is required.') + } + + return { + username, + token, + output, + maxRows, + } +} diff --git a/src/github/client.ts b/src/github/client.ts new file mode 100644 index 0000000..843f07e --- /dev/null +++ b/src/github/client.ts @@ -0,0 +1,9 @@ +import { getOctokit } from '@actions/github' + +export type GitHubClient = ReturnType + +export function createGitHubClient(token: string): GitHubClient { + return getOctokit(token, { + userAgent: 'github-pr-stats-action/1.0.0', + }) +} diff --git a/src/github/pulls.ts b/src/github/pulls.ts new file mode 100644 index 0000000..d56cf57 --- /dev/null +++ b/src/github/pulls.ts @@ -0,0 +1,408 @@ +import type { GitHubUserPullRequests, PullRequestRecord } from '../types' + +import type { GitHubClient } from './client' + +import { withGitHubRetry } from './retry' + +const MAX_SEARCH_RESULTS = 1000 +const PER_PAGE = 100 + +class IncompleteSearchError extends Error { + constructor( + public readonly start: Date, + public readonly end: Date, + ) { + super( + `GitHub returned incomplete search results ` + + `for ${toSearchTimestamp(start)}` + + `..${toSearchTimestamp(end)}.`, + ) + + this.name = 'IncompleteSearchError' + } +} + +function normalizeToSecond( + date: Date, +): Date { + return new Date( + Math.floor(date.getTime() / 1000) * 1000, + ) +} + +function toSearchTimestamp( + date: Date, +): string { + return normalizeToSecond(date) + .toISOString() + .replace('.000Z', 'Z') +} + +function buildSearchQuery( + username: string, + start: Date, + end: Date, +): string { + return [ + `author:${username}`, + 'is:pr', + `created:${toSearchTimestamp(start)}..${toSearchTimestamp(end)}`, + ].join(' ') +} + +// eslint-disable-next-line ts/explicit-function-return-type +async function searchPage( + client: GitHubClient, + username: string, + start: Date, + end: Date, + page: number, +) { + const q = buildSearchQuery( + username, + start, + end, + ) + + return withGitHubRetry( + () => client.rest.search.issuesAndPullRequests({ + q, + sort: 'created', + order: 'asc', + per_page: PER_PAGE, + page, + }), + `Search pull requests page ${page}`, + ) +} + +type SearchResponse = Awaited< + ReturnType +> + +type SearchItem + = SearchResponse['data']['items'][number] + +function parseRepository( + repositoryUrl: string, +): string { + const url = new URL(repositoryUrl) + + const parts = url.pathname + .split('/') + .filter(Boolean) + + const reposIndex = parts + .lastIndexOf('repos') + + const owner = parts[reposIndex + 1] + const repo = parts[reposIndex + 2] + + if (!owner || !repo) { + throw new Error( + `Unable to parse repository from URL: ${repositoryUrl}`, + ) + } + + return `${owner}/${repo}` +} + +function toPullRequestRecord( + item: SearchItem, +): PullRequestRecord { + return { + id: item.id, + repository: parseRepository( + item.repository_url, + ), + number: item.number, + createdAt: item.created_at, + url: item.html_url, + } +} + +function canSplitInterval( + start: Date, + end: Date, +): boolean { + const startSecond = Math.floor( + start.getTime() / 1000, + ) + + const endSecond = Math.floor( + end.getTime() / 1000, + ) + + return startSecond < endSecond +} + +function splitInterval( + start: Date, + end: Date, +): { + left: [Date, Date] + right: [Date, Date] +} { + const startSecond = Math.floor( + start.getTime() / 1000, + ) + + const endSecond = Math.floor( + end.getTime() / 1000, + ) + + if (startSecond >= endSecond) { + throw new Error( + `Unable to split search interval: ` + + `${toSearchTimestamp(start)}` + + `..${toSearchTimestamp(end)}`, + ) + } + + const middleSecond = Math.floor( + (startSecond + endSecond) / 2, + ) + + const middle = new Date( + middleSecond * 1000, + ) + + const rightStart = new Date( + (middleSecond + 1) * 1000, + ) + + return { + left: [ + start, + middle, + ], + right: [ + rightStart, + end, + ], + } +} + +async function fetchRemainingPages( + client: GitHubClient, + username: string, + start: Date, + end: Date, + firstResponse: SearchResponse, +): Promise { + if (firstResponse.data.incomplete_results) { + throw new IncompleteSearchError( + start, + end, + ) + } + + const totalCount + = firstResponse.data.total_count + + if (totalCount === 0) { + return [] + } + + const pages = Math.ceil( + totalCount / PER_PAGE, + ) + + const items: SearchItem[] = [ + ...firstResponse.data.items, + ] + + for ( + let page = 2; + page <= pages; + page += 1 + ) { + const response = await searchPage( + client, + username, + start, + end, + page, + ) + + if (response.data.incomplete_results) { + throw new IncompleteSearchError( + start, + end, + ) + } + + items.push( + ...response.data.items, + ) + } + + return items.map( + toPullRequestRecord, + ) +} + +async function collectInterval( + client: GitHubClient, + username: string, + start: Date, + end: Date, +): Promise { + const firstResponse = await searchPage( + client, + username, + start, + end, + 1, + ) + + const needsSplit + = firstResponse.data.incomplete_results + || firstResponse.data.total_count + > MAX_SEARCH_RESULTS + + if (needsSplit) { + if (!canSplitInterval(start, end)) { + throw new Error( + `GitHub returned more than ` + + `${MAX_SEARCH_RESULTS} PRs within one second ` + + `for @${username}.`, + ) + } + + const { + left, + right, + } = splitInterval( + start, + end, + ) + + const leftItems = await collectInterval( + client, + username, + left[0], + left[1], + ) + + const rightItems = await collectInterval( + client, + username, + right[0], + right[1], + ) + + return [ + ...leftItems, + ...rightItems, + ] + } + + try { + return await fetchRemainingPages( + client, + username, + start, + end, + firstResponse, + ) + } + catch (error) { + if ( + error instanceof IncompleteSearchError + && canSplitInterval(start, end) + ) { + const { + left, + right, + } = splitInterval( + start, + end, + ) + + return [ + ...await collectInterval( + client, + username, + left[0], + left[1], + ), + + ...await collectInterval( + client, + username, + right[0], + right[1], + ), + ] + } + + throw error + } +} + +function deduplicatePullRequests( + pullRequests: PullRequestRecord[], +): PullRequestRecord[] { + const map = new Map< + number, + PullRequestRecord + >() + + for (const pullRequest of pullRequests) { + map.set( + pullRequest.id, + pullRequest, + ) + } + + return [ + ...map.values(), + ].sort( + (a, b) => + a.createdAt.localeCompare( + b.createdAt, + ), + ) +} + +export async function getUserPullRequests( + client: GitHubClient, + username: string, +): Promise { + const userResponse = await withGitHubRetry( + () => client.rest.users.getByUsername({ + username, + }), + `Get GitHub user @${username}`, + ) + + const canonicalUsername + = userResponse.data.login + + const accountCreatedAt + = normalizeToSecond( + new Date( + userResponse.data.created_at, + ), + ) + + const now = normalizeToSecond( + new Date(), + ) + + const pullRequests = await collectInterval( + client, + canonicalUsername, + accountCreatedAt, + now, + ) + + return { + username: canonicalUsername, + pullRequests: + deduplicatePullRequests( + pullRequests, + ), + } +} diff --git a/src/github/repositories.ts b/src/github/repositories.ts new file mode 100644 index 0000000..6f071f1 --- /dev/null +++ b/src/github/repositories.ts @@ -0,0 +1,146 @@ +import type { + RepositoryPRStats, +} from '../types' + +import type { + GitHubClient, +} from './client' + +import * as core from '@actions/core' + +import { + getErrorStatus, + withGitHubRetry, +} from './retry' + +const REPOSITORY_CONCURRENCY = 5 + +async function mapWithConcurrency( + items: T[], + concurrency: number, + mapper: ( + item: T, + index: number, + ) => Promise, +): Promise { + if (items.length === 0) { + return [] + } + + const result = Array.from({ length: items.length }) + + let cursor = 0 + + async function worker(): Promise { + while (true) { + const index = cursor + + cursor += 1 + + if (index >= items.length) { + return + } + + const item = items[index]! + + result[index] = await mapper( + item, + index, + ) + } + } + + const workerCount = Math.min( + concurrency, + items.length, + ) + + await Promise.all( + Array.from( + { + length: workerCount, + }, + () => worker(), + ), + ) + + return result +} + +function parseRepositoryName( + fullName: string, +): { + owner: string + repo: string +} { + const slashIndex + = fullName.indexOf('/') + + if (slashIndex <= 0) { + throw new Error( + `Invalid repository name: ${fullName}`, + ) + } + + return { + owner: fullName.slice( + 0, + slashIndex, + ), + repo: fullName.slice( + slashIndex + 1, + ), + } +} + +export async function hydrateRepositoryStars(client: GitHubClient, stats: RepositoryPRStats[]): Promise { + // eslint-disable-next-line ts/ban-ts-comment + // @ts-expect-error + return mapWithConcurrency( + stats, + REPOSITORY_CONCURRENCY, + + async (item) => { + const { + owner, + repo, + } = parseRepositoryName( + item.repo, + ) + + try { + const response + = await withGitHubRetry( + () => client.rest.repos.get({ + owner, + repo, + }), + `Get repository ${item.repo}`, + ) + + return { + ...item, + star: + response.data + .stargazers_count ?? 0, + } + } + catch (error) { + if ( + getErrorStatus(error) + === 404 + ) { + core.warning( + `Repository ${item.repo} ` + + `is no longer accessible. ` + + `Star count will be 0.`, + ) + + return item + } + + throw error + } + }, + ) +} diff --git a/src/github/retry.ts b/src/github/retry.ts new file mode 100644 index 0000000..a67748a --- /dev/null +++ b/src/github/retry.ts @@ -0,0 +1,221 @@ +import * as core from '@actions/core' + +type UnknownRecord = Record + +function asRecord( + value: unknown, +): UnknownRecord | undefined { + if ( + typeof value !== 'object' + || value === null + ) { + return undefined + } + + return value as UnknownRecord +} + +export function getErrorStatus( + error: unknown, +): number | undefined { + const value = asRecord(error)?.status + + if (typeof value === 'number') { + return value + } + + if ( + typeof value === 'string' + && value.trim() + ) { + const parsed = Number(value) + + return Number.isFinite(parsed) + ? parsed + : undefined + } + + return undefined +} + +function getErrorMessage( + error: unknown, +): string { + if (error instanceof Error) { + return error.message + } + + const message = asRecord(error)?.message + + return typeof message === 'string' + ? message + : String(error) +} + +function getHeader( + error: unknown, + name: string, +): string | undefined { + const response = asRecord( + asRecord(error)?.response, + ) + + const headers = asRecord( + response?.headers, + ) + + if (!headers) { + return undefined + } + + const value + = headers[name] + ?? headers[name.toLowerCase()] + + if ( + typeof value === 'string' + || typeof value === 'number' + ) { + return String(value) + } + + return undefined +} + +function isRateLimitError( + error: unknown, +): boolean { + const status = getErrorStatus(error) + + if (status === 429) { + return true + } + + if (status !== 403) { + return false + } + + const remaining = getHeader( + error, + 'x-ratelimit-remaining', + ) + + if (remaining === '0') { + return true + } + + const message = getErrorMessage(error) + .toLowerCase() + + return ( + message.includes('rate limit') + || message.includes('secondary rate') + ) +} + +function shouldRetry( + error: unknown, +): boolean { + const status = getErrorStatus(error) + + if (isRateLimitError(error)) { + return true + } + + return ( + status === 502 + || status === 503 + || status === 504 + ) +} + +function resolveRetryDelay( + error: unknown, + attempt: number, +): number { + const retryAfter = Number( + getHeader(error, 'retry-after'), + ) + + if ( + Number.isFinite(retryAfter) + && retryAfter > 0 + ) { + return retryAfter * 1000 + } + + const reset = Number( + getHeader(error, 'x-ratelimit-reset'), + ) + + if ( + Number.isFinite(reset) + && reset > 0 + ) { + return Math.max( + reset * 1000 - Date.now() + 1000, + 1000, + ) + } + + const exponential + = 1000 * (2 ** attempt) + + const jitter + = Math.floor(Math.random() * 500) + + return Math.min( + exponential + jitter, + 30_000, + ) +} + +function sleep( + milliseconds: number, +): Promise { + return new Promise((resolve) => { + setTimeout(resolve, milliseconds) + }) +} + +export async function withGitHubRetry( + task: () => Promise, + label: string, + maxAttempts = 4, +): Promise { + let lastError: unknown + + for ( + let attempt = 0; + attempt < maxAttempts; + attempt += 1 + ) { + try { + return await task() + } + catch (error) { + lastError = error + + if ( + !shouldRetry(error) + || attempt === maxAttempts - 1 + ) { + throw error + } + + const delay = resolveRetryDelay( + error, + attempt, + ) + + core.warning( + `${label} failed: ${getErrorMessage(error)}. ` + + `Retrying in ${Math.ceil(delay / 1000)}s.`, + ) + + await sleep(delay) + } + } + + throw lastError +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..289a01b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,104 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import path from 'node:path' +import * as core from '@actions/core' +import { resolveConfig } from './config' +import { createGitHubClient } from './github/client' +import { getUserPullRequests } from './github/pulls' +import { hydrateRepositoryStars } from './github/repositories' +import { aggregatePullRequests, sortRepositoryStats } from './stats/aggregate' +import { renderSvg } from './svg/render' + +async function runGroup(title: string, task: () => Promise): Promise { + core.startGroup(title) + + try { + return await task() + } + finally { + core.endGroup() + } +} + +function resolveOutputPath(output: string): string { + if (path.isAbsolute(output)) { + return output + } + + const workspace + = process.env.GITHUB_WORKSPACE + || process.cwd() + + return path.resolve( + workspace, + output, + ) +} + +async function run(): Promise { + const config = resolveConfig() + + core.setSecret( + config.token, + ) + + const client = createGitHubClient( + config.token, + ) + + const { + username, + pullRequests, + } = await runGroup( + `Collect pull requests for @${config.username}`, + + () => getUserPullRequests( + client, + config.username, + ), + ) + + core.info(`Found ${pullRequests.length} pull requests.`) + + let stats = aggregatePullRequests(pullRequests) + + core.info(`Found contributions across ${stats.length} repositories.`) + + stats = await runGroup('Fetch repository metadata', () => hydrateRepositoryStars(client, stats)) + + sortRepositoryStats(stats) + + const svg = renderSvg({ username, stats, maxRows: config.maxRows }) + + const outputPath = resolveOutputPath(config.output) + + await mkdir(path.dirname(outputPath), { recursive: true }) + + await writeFile(outputPath, svg, 'utf8') + + const totalPullRequests = stats.reduce((total, repository) => total + repository.pr_count, 0) + + core.setOutput('data', JSON.stringify(stats)) + + core.setOutput('svg-path', outputPath) + + core.setOutput('repository-count', stats.length) + + core.setOutput('pr-count', totalPullRequests) + + core.info(`SVG generated: ${outputPath}`) + + core.info(`Repositories: ${stats.length}`) + + core.info(`Pull requests: ${totalPullRequests}`) +} + +run().catch( + (error: unknown) => { + if (error instanceof Error) { + core.setFailed(error.message) + return + } + + core.setFailed(String(error)) + }, +) diff --git a/src/stats/aggregate.ts b/src/stats/aggregate.ts new file mode 100644 index 0000000..44f7afd --- /dev/null +++ b/src/stats/aggregate.ts @@ -0,0 +1,76 @@ +import type { + PullRequestRecord, + RepositoryPRStats, +} from '../types' + +export function aggregatePullRequests( + pullRequests: PullRequestRecord[], +): RepositoryPRStats[] { + const repositories = new Map< + string, + RepositoryPRStats + >() + + for (const pullRequest of pullRequests) { + const current = repositories.get( + pullRequest.repository, + ) + + if (!current) { + repositories.set( + pullRequest.repository, + { + repo: pullRequest.repository, + pr_count: 1, + first_pr: pullRequest.createdAt, + latest_pr_time: + pullRequest.createdAt, + star: 0, + }, + ) + + continue + } + + current.pr_count += 1 + + if ( + pullRequest.createdAt + < current.first_pr + ) { + current.first_pr + = pullRequest.createdAt + } + + if ( + pullRequest.createdAt + > current.latest_pr_time + ) { + current.latest_pr_time + = pullRequest.createdAt + } + } + + return [ + ...repositories.values(), + ] +} + +export function sortRepositoryStats( + stats: RepositoryPRStats[], +): RepositoryPRStats[] { + return stats.sort( + (a, b) => { + const countDiff + = b.pr_count - a.pr_count + + if (countDiff !== 0) { + return countDiff + } + + return a.repo.localeCompare( + b.repo, + ) + }, + ) +} diff --git a/src/svg/render.ts b/src/svg/render.ts new file mode 100644 index 0000000..862106e --- /dev/null +++ b/src/svg/render.ts @@ -0,0 +1,390 @@ +import type { + RepositoryPRStats, +} from '../types' + +export interface RenderSvgOptions { + username: string + stats: RepositoryPRStats[] + maxRows: number +} + +const WIDTH = 960 + +const TITLE_HEIGHT = 72 +const HEADER_HEIGHT = 42 +const ROW_HEIGHT = 44 +const FOOTER_HEIGHT = 18 + +function escapeXml( + value: string, +): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll('\'', ''') +} + +function truncate( + value: string, + maxLength: number, +): string { + if (value.length <= maxLength) { + return value + } + + return `${value.slice( + 0, + maxLength - 1, + )}…` +} + +function formatDate( + timestamp: string, +): string { + return timestamp.slice(0, 10) +} + +function formatNumber( + value: number, +): string { + return new Intl.NumberFormat( + 'en-US', + ).format(value) +} + +function formatStars( + value: number, +): string { + if (value >= 1_000_000) { + return `${ + (value / 1_000_000) + .toFixed(1) + .replace(/\.0$/, '') + }m` + } + + if (value >= 1_000) { + return `${ + (value / 1_000) + .toFixed(1) + .replace(/\.0$/, '') + }k` + } + + return String(value) +} + +export function renderSvg({ + username, + stats, + maxRows, +}: RenderSvgOptions): string { + const visibleStats + = maxRows > 0 + ? stats.slice(0, maxRows) + : stats + + const totalPRs = stats.reduce( + (total, item) => + total + item.pr_count, + 0, + ) + + const height + = TITLE_HEIGHT + + HEADER_HEIGHT + + visibleStats.length + * ROW_HEIGHT + + FOOTER_HEIGHT + + const rows = visibleStats + .map( + ( + item, + index, + ) => { + const y + = TITLE_HEIGHT + + HEADER_HEIGHT + + index * ROW_HEIGHT + + const centerY + = y + ROW_HEIGHT / 2 + + const rowClass + = index % 2 === 0 + ? 'row' + : 'row row-alt' + + const repository + = escapeXml( + truncate( + item.repo, + 42, + ), + ) + + const repositoryUrl + = escapeXml( + `https://github.com/${item.repo}`, + ) + + return ` + + + + + + + ${repository} + + + ${formatNumber(item.pr_count)} + + ${formatDate(item.first_pr)} + + ${formatDate(item.latest_pr_time)} + + ${formatStars(item.star)} + + ` + }, + ) + .join('') + + const truncatedNotice + = visibleStats.length < stats.length + ? ` · showing ${visibleStats.length} of ${stats.length}` + : '' + + return ` + + + Pull Request contributions for @${escapeXml(username)} + + + + Repository-level GitHub pull request contribution statistics. + + + + + + + @${escapeXml(username)} · Pull Request Contributions + + ${formatNumber(totalPRs)} PRs · ${formatNumber(stats.length)} repositories${escapeXml(truncatedNotice)} + + + + + + Repository + + PR Count + + First PR + + Latest PR + + Stars + + ${rows} + +` +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..4dc29dd --- /dev/null +++ b/src/types.ts @@ -0,0 +1,20 @@ +export interface PullRequestRecord { + id: number + repository: string + number: number + createdAt: string + url: string +} + +export interface RepositoryPRStats { + repo: string + pr_count: number + first_pr: string + latest_pr_time: string + star: number +} + +export interface GitHubUserPullRequests { + username: string + pullRequests: PullRequestRecord[] +}