From e8bd565f93e1d20b373aaf24b88532ea5a76e882 Mon Sep 17 00:00:00 2001 From: lucifer1017 Date: Thu, 19 Mar 2026 14:48:26 +0530 Subject: [PATCH] Added Dev Metrics functionality --- README.md | 72 +++- bin/index.ts | 70 ++++ package-lock.json | 199 +++++++++- package.json | 5 +- src/commands/devmetrics.ts | 231 +++++++++++ src/devmetrics/formatters/index.ts | 19 + src/devmetrics/formatters/jsonFormatter.ts | 5 + .../formatters/markdownFormatter.ts | 75 ++++ src/devmetrics/formatters/tableFormatter.ts | 100 +++++ src/devmetrics/services/githubService.ts | 181 +++++++++ src/devmetrics/services/rootstockService.ts | 371 ++++++++++++++++++ src/devmetrics/types.ts | 42 ++ src/devmetrics/validation.ts | 29 ++ 13 files changed, 1392 insertions(+), 7 deletions(-) create mode 100644 src/commands/devmetrics.ts create mode 100644 src/devmetrics/formatters/index.ts create mode 100644 src/devmetrics/formatters/jsonFormatter.ts create mode 100644 src/devmetrics/formatters/markdownFormatter.ts create mode 100644 src/devmetrics/formatters/tableFormatter.ts create mode 100644 src/devmetrics/services/githubService.ts create mode 100644 src/devmetrics/services/rootstockService.ts create mode 100644 src/devmetrics/types.ts create mode 100644 src/devmetrics/validation.ts diff --git a/README.md b/README.md index 76db5ee..4c6c741 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ 10. [Batch Transfer](#10-batch-transfer) 11. [Transaction Simulation](#11-transaction-simulation) 12. [RNS Operations](#12-rns-operations) + 13. [Developer Metrics](#13-developer-metrics) - [Contributing](#contributing) ## Installation @@ -1087,8 +1088,75 @@ The command provides: - Cost in RBTC and Wei - Recommended gas limits (with buffers) - Optimization tips (if applicable) -======= ->>>>>>> main + +### 13. Developer Metrics + +The `devmetrics` command generates a combined developer health report using both GitHub repository activity and Rootstock contract activity, with output options for terminal, JSON, or Markdown. + +#### Basic Usage + +```bash +rsk-cli devmetrics --repo owner/repo --contract 0xYourContractAddress +``` + +#### Network Selection + +```bash +# Mainnet (default) +rsk-cli devmetrics --repo owner/repo --contract 0xYourContractAddress + +# Testnet +rsk-cli devmetrics --repo owner/repo --contract 0xYourContractAddress --network testnet +``` + +#### Output Formats + +```bash +# Table output (default) +rsk-cli devmetrics --repo owner/repo --contract 0xYourContractAddress --format table + +# JSON output +rsk-cli devmetrics --repo owner/repo --contract 0xYourContractAddress --format json + +# Markdown output +rsk-cli devmetrics --repo owner/repo --contract 0xYourContractAddress --format markdown + +# CI mode (forces JSON output) +rsk-cli devmetrics --repo owner/repo --contract 0xYourContractAddress --ci +``` + +#### Multiple Repositories / Contracts + +```bash +# Apply one contract to multiple repos +rsk-cli devmetrics --repo owner/repo1 --repo owner/repo2 --contract 0xYourContractAddress + +# Apply one repo to multiple contracts +rsk-cli devmetrics --repo owner/repo --contract 0xContract1 --contract 0xContract2 + +# Pair repos and contracts one-to-one +rsk-cli devmetrics --repo owner/repo1 --contract 0xContract1 --repo owner/repo2 --contract 0xContract2 +``` + +#### Authentication and RPC + +```bash +# Use GitHub token to increase API rate limits +rsk-cli devmetrics --repo owner/repo --contract 0xYourContractAddress --github-token ghp_xxx + +# Use custom RPC URL +rsk-cli devmetrics --repo owner/repo --contract 0xYourContractAddress --rpc-url https://your-rpc-url +``` + +You can also configure `devmetrics` via environment variables: + +```env +GITHUB_TOKEN=your_github_token_here + +# You can use custom RPC URLs for better performance and rate limits +ROOTSTOCK_MAINNET_RPC_URL=https://public-node.rsk.co +ROOTSTOCK_TESTNET_RPC_URL=https://public-node.testnet.rsk.co +``` ## Contributing diff --git a/bin/index.ts b/bin/index.ts index 3c09961..eb49d47 100644 --- a/bin/index.ts +++ b/bin/index.ts @@ -28,6 +28,7 @@ import { validateAndFormatAddressRSK } from "../src/utils/index.js"; import { rnsUpdateCommand } from "../src/commands/rnsUpdate.js"; import { rnsTransferCommand } from "../src/commands/rnsTransfer.js"; import { rnsRegisterCommand } from "../src/commands/rnsRegister.js"; +import { devmetricsCommand } from "../src/commands/devmetrics.js"; interface CommandOptions { testnet?: boolean; @@ -696,4 +697,73 @@ program } }); +// ─── devmetrics command ──────────────────────────────────────────────────────── +// Collects repeated flag values into an array (e.g. --repo a --repo b → ['a','b']) +function collectRepeatable(val: string, prev: string[]): string[] { + return [...prev, val]; +} + +program + .command("devmetrics") + .description( + "Aggregate GitHub and Rootstock on-chain metrics into a dApp health report" + ) + .option( + "-r, --repo ", + "GitHub repo in owner/repo format — repeat for multiple repos", + collectRepeatable, + [] as string[] + ) + .option( + "-c, --contract
", + "Rootstock contract address — repeat for multiple contracts", + collectRepeatable, + [] as string[] + ) + .option( + "-f, --format ", + "Output format: table | json | markdown", + "table" + ) + .option("--ci", "CI/CD mode — forces JSON output (overrides --format)", false) + .option( + "--github-token ", + "GitHub personal access token (or set GITHUB_TOKEN env var)" + ) + .option( + "-n, --network ", + "Rootstock network: mainnet or testnet", + "mainnet" + ) + .option( + "--rpc-url ", + "Custom Rootstock RPC URL (overrides --network default)" + ) + .action( + async (opts: { + repo: string[]; + contract: string[]; + format: string; + ci: boolean; + githubToken?: string; + network: string; + rpcUrl?: string; + }) => { + try { + await devmetricsCommand({ + repos: opts.repo, + contracts: opts.contract, + format: opts.format as "table" | "json" | "markdown", + ci: opts.ci, + githubToken: opts.githubToken, + network: opts.network as "mainnet" | "testnet", + rpcUrl: opts.rpcUrl, + }); + } catch (error: any) { + logError(false, `devmetrics error: ${error.message || error}`); + process.exit(1); + } + } + ); + program.parse(process.argv); diff --git a/package-lock.json b/package-lock.json index 0ed9d31..a8a25c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@ethereum-attestation-service/eas-sdk": "^2.7.0", + "@octokit/rest": "^22.0.1", "@openzeppelin/contracts": "^5.0.2", "@rsksmart/rns-resolver.js": "^1.1.0", "@rsksmart/rns-sdk": "^1.0.0-beta.9", @@ -94,9 +95,9 @@ } }, "node_modules/@ethereum-attestation-service/eas-sdk": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@ethereum-attestation-service/eas-sdk/-/eas-sdk-2.9.0.tgz", - "integrity": "sha512-jEtBlhfm0HFkl64jAa4rxOXjEQkblTHqSmLFhttPf9y+ALEOk4qgJzV9knnJ7Yh+jFs1jxbTrVeUGap03Fwy9g==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@ethereum-attestation-service/eas-sdk/-/eas-sdk-2.7.0.tgz", + "integrity": "sha512-JpbUty2ab+FK5AZdoNhH/yCAUfruC2J6sxIhOaF923qip4ibs3M8XwFRr8R67xWEzcML7WKn8rFz0icJiJnxUA==", "license": "MIT", "dependencies": { "@ethereum-attestation-service/eas-contracts": "1.7.1", @@ -1876,6 +1877,162 @@ "node": ">= 12" } }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz", + "integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "fast-content-type-parse": "^3.0.0", + "json-with-bigint": "^3.5.3", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, "node_modules/@openzeppelin/contracts": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-5.4.0.tgz", @@ -2357,6 +2514,12 @@ "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==", "license": "MIT" }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "license": "Apache-2.0" + }, "node_modules/big-integer": { "version": "1.6.36", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.36.tgz", @@ -3240,6 +3403,22 @@ "safe-buffer": "^5.1.1" } }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/figlet": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/figlet/-/figlet-1.10.0.tgz", @@ -4303,6 +4482,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/json-with-bigint": { + "version": "3.5.7", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.7.tgz", + "integrity": "sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw==", + "license": "MIT" + }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", @@ -5632,6 +5817,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5658,6 +5844,12 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -5828,6 +6020,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index cf113e6..7247d9a 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "typescript": "^5.0.0" }, "dependencies": { - "@ethereum-attestation-service/eas-sdk": "^2.7.0", + "@ethereum-attestation-service/eas-sdk": "2.7.0", + "@octokit/rest": "^22.0.1", "@openzeppelin/contracts": "^5.0.2", "@rsksmart/rns-resolver.js": "^1.1.0", "@rsksmart/rns-sdk": "^1.0.0-beta.9", @@ -57,8 +58,8 @@ "cli-table3": "^0.6.5", "commander": "^13.1.0", "dotenv": "^16.3.1", - "figlet": "^1.7.0", "ethers": "^5.8.0", + "figlet": "^1.7.0", "fs-extra": "^11.2.0", "inquirer": "^12.1.0", "ora": "^8.0.1", diff --git a/src/commands/devmetrics.ts b/src/commands/devmetrics.ts new file mode 100644 index 0000000..7405b75 --- /dev/null +++ b/src/commands/devmetrics.ts @@ -0,0 +1,231 @@ +import chalk from "chalk"; +import { GitHubService } from "../devmetrics/services/githubService.js"; +import { + RootstockMetricsService, + type Network, +} from "../devmetrics/services/rootstockService.js"; +import { formatReport } from "../devmetrics/formatters/index.js"; +import { + validateRepo, + validateContractAddress, + validateOutputFormat, +} from "../devmetrics/validation.js"; +import type { + DevMetricsOptions, + DevMetricsReport, + OutputFormat, +} from "../devmetrics/types.js"; + +// ─── Public command entry point ──────────────────────────────────────────────── + +export async function devmetricsCommand( + opts: DevMetricsOptions +): Promise { + // 1. Resolve output format (--ci overrides --format) + let format: OutputFormat = "table"; + if (opts.ci) { + format = "json"; + } else if (opts.format) { + if (!validateOutputFormat(opts.format)) { + console.error( + chalk.red( + `❌ Invalid format "${opts.format}". Must be one of: table, json, markdown.` + ) + ); + process.exit(1); + } + format = opts.format as OutputFormat; + } + + // 2. Ensure at least one repo and one contract were provided + if (opts.repos.length === 0) { + console.error( + chalk.red( + "❌ At least one repository is required (use --repo owner/repo)." + ) + ); + process.exit(1); + } + if (opts.contracts.length === 0) { + console.error( + chalk.red( + "❌ At least one contract address is required (use --contract 0x...)." + ) + ); + process.exit(1); + } + + // 3. Validate network flag + if (opts.network && !["mainnet", "testnet"].includes(opts.network)) { + console.error( + chalk.red( + `❌ Invalid network "${opts.network}". Must be "mainnet" or "testnet".` + ) + ); + process.exit(1); + } + const network = (opts.network ?? "mainnet") as Network; + + // 4. Build repo/contract pairs + const pairs = buildPairs(opts.repos, opts.contracts); + if (pairs === null) { + console.error( + chalk.red( + "❌ The number of --repo and --contract flags must match, or one of them must be a single value applied to all." + ) + ); + process.exit(1); + } + + // 5. Validate every repo name and address + const validationErrors: string[] = []; + for (const p of pairs) { + const rv = validateRepo(p.repo); + if (!rv.valid) validationErrors.push(`Repo "${p.repo}": ${rv.error}`); + + const cv = validateContractAddress(p.contract); + if (!cv.valid) + validationErrors.push(`Contract "${p.contract}": ${cv.error}`); + } + if (validationErrors.length > 0) { + console.error(chalk.red("❌ Validation errors:")); + validationErrors.forEach((e) => console.error(chalk.red(` • ${e}`))); + process.exit(1); + } + + // 6. Initialise services + const github = new GitHubService(opts.githubToken); + const rootstockSvc = new RootstockMetricsService(opts.rpcUrl, network); + + // 7. Print header (table mode only — keep JSON/Markdown clean) + if (format === "table") { + console.log( + chalk.cyan( + `\n🌐 Rootstock Network: ${chalk.bold(rootstockSvc.getNetwork().toUpperCase())}` + ) + ); + console.log(chalk.gray(` RPC: ${rootstockSvc.getRpcUrl()}\n`)); + + if (!github.isAuthenticated()) { + console.log( + chalk.yellow( + "⚠️ No GitHub token detected — using unauthenticated mode (60 req/hour)." + ) + ); + console.log( + chalk.yellow( + " Pass --github-token or set GITHUB_TOKEN for 5,000 req/hour.\n" + ) + ); + } + + try { + const rl = await github.getRateLimitStatus(); + if (rl) { + const pct = ((rl.remaining / rl.limit) * 100).toFixed(1); + const color = + rl.remaining < rl.limit * 0.1 + ? chalk.red + : rl.remaining < rl.limit * 0.3 + ? chalk.yellow + : chalk.green; + console.log( + color( + `📊 GitHub API: ${rl.remaining}/${rl.limit} requests remaining (${pct}%)\n` + ) + ); + } + } catch { + // Rate-limit check is best-effort; never block the main flow. + } + } + + // 8. Fetch data for each pair, collect results and errors + const reports: DevMetricsReport[] = []; + const errors: Array<{ pair: { repo: string; contract: string }; error: string }> = []; + + for (const pair of pairs) { + try { + if (format === "table") { + console.log(chalk.blue(`📡 Fetching metrics for ${pair.repo}…`)); + } + + const [owner, repo] = pair.repo.split("/") as [string, string]; + + // GitHub + let githubMetrics; + try { + if (format === "table") process.stdout.write(chalk.gray(" GitHub data… ")); + githubMetrics = await github.getMetrics(owner, repo); + if (format === "table") console.log(chalk.green("✓")); + } catch (err: any) { + if (format === "table") console.log(chalk.red("✗")); + throw err; + } + + // Rootstock (non-fatal: a missing/undeployed contract returns a note instead of throwing) + let rootstockMetrics; + try { + if (format === "table") process.stdout.write(chalk.gray(" Rootstock data… ")); + rootstockMetrics = await rootstockSvc.getMetrics(pair.contract); + if (format === "table") { + if (rootstockMetrics.note) { + console.log(chalk.yellow("⚠️ (partial — see report)")); + } else { + console.log(chalk.green("✓")); + } + } + } catch (err: any) { + if (format === "table") console.log(chalk.red("✗")); + throw err; + } + + reports.push({ + repository: pair.repo, + contractAddress: pair.contract, + github: githubMetrics, + rootstock: rootstockMetrics, + timestamp: new Date().toISOString(), + }); + } catch (err: any) { + errors.push({ pair, error: err.message ?? "Unknown error" }); + } + } + + // 9. Output results + if (reports.length > 0) { + console.log(formatReport(reports, format)); + } + + // 10. Report any per-pair hard errors (GitHub failures, timeouts, etc.) + if (errors.length > 0) { + if (format === "json") { + console.error(JSON.stringify({ errors }, null, 2)); + } else { + console.error(chalk.red("\n❌ Errors encountered:")); + errors.forEach(({ pair, error }) => + console.error(chalk.red(` ${pair.repo} / ${pair.contract}: ${error}`)) + ); + } + // Only exit non-zero when there are genuine hard failures. + if (reports.length === 0) process.exit(1); + } +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function buildPairs( + repos: string[], + contracts: string[] +): Array<{ repo: string; contract: string }> | null { + if (repos.length === contracts.length) { + return repos.map((repo, i) => ({ repo, contract: contracts[i] })); + } + if (contracts.length === 1) { + return repos.map((repo) => ({ repo, contract: contracts[0] })); + } + if (repos.length === 1) { + return contracts.map((contract) => ({ repo: repos[0], contract })); + } + return null; // mismatch +} diff --git a/src/devmetrics/formatters/index.ts b/src/devmetrics/formatters/index.ts new file mode 100644 index 0000000..8729202 --- /dev/null +++ b/src/devmetrics/formatters/index.ts @@ -0,0 +1,19 @@ +import type { DevMetricsReport, OutputFormat } from "../types.js"; +import { formatAsTable } from "./tableFormatter.js"; +import { formatAsJSON } from "./jsonFormatter.js"; +import { formatAsMarkdown } from "./markdownFormatter.js"; + +export function formatReport( + reports: DevMetricsReport[], + format: OutputFormat +): string { + switch (format) { + case "json": + return formatAsJSON(reports); + case "markdown": + return formatAsMarkdown(reports); + case "table": + default: + return formatAsTable(reports); + } +} diff --git a/src/devmetrics/formatters/jsonFormatter.ts b/src/devmetrics/formatters/jsonFormatter.ts new file mode 100644 index 0000000..387f17e --- /dev/null +++ b/src/devmetrics/formatters/jsonFormatter.ts @@ -0,0 +1,5 @@ +import type { DevMetricsReport } from "../types.js"; + +export function formatAsJSON(reports: DevMetricsReport[]): string { + return JSON.stringify(reports, null, 2); +} diff --git a/src/devmetrics/formatters/markdownFormatter.ts b/src/devmetrics/formatters/markdownFormatter.ts new file mode 100644 index 0000000..7b840bf --- /dev/null +++ b/src/devmetrics/formatters/markdownFormatter.ts @@ -0,0 +1,75 @@ +import type { DevMetricsReport } from "../types.js"; + +export function formatAsMarkdown(reports: DevMetricsReport[]): string { + const lines: string[] = []; + + for (const report of reports) { + lines.push(`# Developer Health Report: ${report.repository}`); + lines.push(""); + lines.push(`**Contract Address:** \`${report.contractAddress}\``); + lines.push( + `**Generated:** ${new Date(report.timestamp).toLocaleString()}` + ); + lines.push(""); + + lines.push("## 📊 GitHub Metrics"); + lines.push(""); + lines.push("| Metric | Value |"); + lines.push("|--------|-------|"); + lines.push(`| ⭐ Stars | ${report.github.stars} |`); + lines.push( + `| 📝 Last Commit | ${ + report.github.lastCommitDate + ? new Date(report.github.lastCommitDate).toLocaleDateString() + : "N/A" + } |` + ); + lines.push(`| 🐛 Open Issues | ${report.github.openIssuesCount} |`); + lines.push(`| 🔀 Open PRs | ${report.github.pullRequestsCount} |`); + lines.push(`| 👥 Contributors | ${report.github.contributorCount} |`); + lines.push(""); + + lines.push("## ⛓️ Rootstock Metrics"); + lines.push(""); + if (report.rootstock.note) { + lines.push(`> ⚠️ ${report.rootstock.note}`); + lines.push(""); + } + lines.push("| Metric | Value |"); + lines.push("|--------|-------|"); + lines.push( + `| 📦 Deployment Block | ${ + report.rootstock.deploymentBlock ?? "N/A" + } |` + ); + lines.push( + `| 📊 Total Txs (est.) | ${report.rootstock.totalTransactionCount} |` + ); + lines.push( + `| ⏰ Last Transaction | ${ + report.rootstock.lastTransactionTimestamp + ? new Date( + report.rootstock.lastTransactionTimestamp + ).toLocaleDateString() + : "N/A" + } |` + ); + lines.push( + `| ⛽ Avg Gas Used | ${report.rootstock.gasUsagePatterns.average.toLocaleString()} |` + ); + lines.push( + `| ⛽ Min Gas Used | ${report.rootstock.gasUsagePatterns.min.toLocaleString()} |` + ); + lines.push( + `| ⛽ Max Gas Used | ${report.rootstock.gasUsagePatterns.max.toLocaleString()} |` + ); + lines.push(""); + + if (reports.length > 1) { + lines.push("---"); + lines.push(""); + } + } + + return lines.join("\n"); +} diff --git a/src/devmetrics/formatters/tableFormatter.ts b/src/devmetrics/formatters/tableFormatter.ts new file mode 100644 index 0000000..5db9576 --- /dev/null +++ b/src/devmetrics/formatters/tableFormatter.ts @@ -0,0 +1,100 @@ +import Table from "cli-table3"; +import chalk from "chalk"; +import type { DevMetricsReport } from "../types.js"; + +export function formatAsTable(reports: DevMetricsReport[]): string { + const lines: string[] = []; + + for (const report of reports) { + lines.push( + chalk.bold.cyan(`\n📊 Report for ${report.repository}`) + ); + lines.push(chalk.gray(` Contract : ${report.contractAddress}`)); + lines.push( + chalk.gray( + ` Generated: ${new Date(report.timestamp).toLocaleString()}\n` + ) + ); + + // ── GitHub ────────────────────────────────────────────────────────────── + const ghTable = new Table({ + head: [chalk.blue("GitHub Metrics"), chalk.yellow("Value")], + style: { head: [], border: [] }, + }); + + ghTable.push( + ["⭐ Stars", chalk.white(report.github.stars.toString())], + [ + "📝 Last Commit", + report.github.lastCommitDate + ? chalk.green( + new Date(report.github.lastCommitDate).toLocaleDateString() + ) + : chalk.red("N/A"), + ], + ["🐛 Open Issues", chalk.white(report.github.openIssuesCount.toString())], + ["🔀 Open PRs", chalk.white(report.github.pullRequestsCount.toString())], + [ + "👥 Contributors", + chalk.white(report.github.contributorCount.toString()), + ] + ); + + lines.push(ghTable.toString()); + + // ── Rootstock ──────────────────────────────────────────────────────────── + const rskTable = new Table({ + head: [chalk.blue("Rootstock Metrics"), chalk.yellow("Value")], + style: { head: [], border: [] }, + }); + + if (report.rootstock.note) { + rskTable.push([ + { colSpan: 2, content: chalk.yellow(`⚠️ ${report.rootstock.note}`) }, + ]); + } + + rskTable.push( + [ + "📦 Deployment Block", + report.rootstock.deploymentBlock != null + ? chalk.white(report.rootstock.deploymentBlock.toLocaleString()) + : chalk.gray("N/A"), + ], + [ + "📊 Total Txs (est.)", + chalk.white(report.rootstock.totalTransactionCount.toString()), + ], + [ + "⏰ Last Transaction", + report.rootstock.lastTransactionTimestamp + ? chalk.green( + new Date( + report.rootstock.lastTransactionTimestamp + ).toLocaleDateString() + ) + : chalk.gray("N/A"), + ], + [ + "⛽ Avg Gas Used", + chalk.white( + report.rootstock.gasUsagePatterns.average.toLocaleString() + ), + ], + [ + "⛽ Min Gas Used", + chalk.white(report.rootstock.gasUsagePatterns.min.toLocaleString()), + ], + [ + "⛽ Max Gas Used", + chalk.white(report.rootstock.gasUsagePatterns.max.toLocaleString()), + ] + ); + + lines.push("\n" + rskTable.toString()); + + if (reports.length > 1) lines.push(""); + } + + return lines.join("\n"); +} diff --git a/src/devmetrics/services/githubService.ts b/src/devmetrics/services/githubService.ts new file mode 100644 index 0000000..a2a0bbc --- /dev/null +++ b/src/devmetrics/services/githubService.ts @@ -0,0 +1,181 @@ +import { Octokit } from "@octokit/rest"; +import type { GitHubMetrics } from "../types.js"; + +export class GitHubService { + private octokit: Octokit; + private hasToken: boolean; + private tokenInvalid = false; + private readonly API_TIMEOUT = 15_000; + private readonly TOTAL_TIMEOUT = 60_000; + + constructor(token?: string) { + const authToken = token ?? process.env.GITHUB_TOKEN; + this.hasToken = !!authToken; + this.octokit = new Octokit({ auth: authToken }); + } + + /** Race a promise against a deadline. */ + private withTimeout(promise: Promise, ms = this.API_TIMEOUT): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`GitHub API call timed out after ${ms}ms`)), + ms + ) + ), + ]); + } + + /** Fall back to unauthenticated mode after a 401 response. */ + private resetToUnauthenticated(): void { + if (this.tokenInvalid) return; + this.tokenInvalid = true; + this.hasToken = false; + this.octokit = new Octokit(); + } + + isAuthenticated(): boolean { + return this.hasToken && !this.tokenInvalid; + } + + async getRateLimitStatus(): Promise<{ + remaining: number; + limit: number; + resetAt: Date; + } | null> { + try { + const { data } = await this.withTimeout( + this.octokit.rateLimit.get(), + 5_000 + ); + return { + remaining: data.rate.remaining, + limit: data.rate.limit, + resetAt: new Date(data.rate.reset * 1000), + }; + } catch (err: any) { + if (err.status === 401 && this.hasToken && !this.tokenInvalid) { + this.resetToUnauthenticated(); + try { + const { data } = await this.withTimeout( + this.octokit.rateLimit.get(), + 5_000 + ); + return { + remaining: data.rate.remaining, + limit: data.rate.limit, + resetAt: new Date(data.rate.reset * 1000), + }; + } catch { + return null; + } + } + return null; + } + } + + async getMetrics(owner: string, repo: string): Promise { + return Promise.race([ + this.fetchMetrics(owner, repo), + new Promise((_, reject) => + setTimeout( + () => + reject( + new Error( + `GitHub metrics timed out after ${this.TOTAL_TIMEOUT}ms` + ) + ), + this.TOTAL_TIMEOUT + ) + ), + ]); + } + + private async fetchMetrics( + owner: string, + repo: string + ): Promise { + try { + const { data: repoData } = await this.withTimeout( + this.octokit.repos.get({ owner, repo }) + ); + + const { data: commits } = await this.withTimeout( + this.octokit.repos.listCommits({ owner, repo, per_page: 1 }) + ); + + const { data: pullRequests } = await this.withTimeout( + this.octokit.pulls.list({ owner, repo, state: "open", per_page: 1 }) + ); + + let prsCount = 0; + if (this.hasToken) { + try { + const { data: prSearch } = await this.withTimeout( + this.octokit.search.issuesAndPullRequests({ + q: `repo:${owner}/${repo} type:pr state:open`, + per_page: 1, + }) + ); + prsCount = prSearch.total_count; + } catch { + prsCount = pullRequests.length; + } + } else { + prsCount = pullRequests.length; + } + + let contributorsCount = 0; + try { + const { data: contributors } = await this.withTimeout( + this.octokit.repos.listContributors({ + owner, + repo, + per_page: this.hasToken ? 100 : 30, + }) + ); + contributorsCount = contributors.length; + } catch { + contributorsCount = 0; + } + + return { + stars: repoData.stargazers_count ?? 0, + lastCommitDate: commits[0]?.commit.committer?.date ?? null, + openIssuesCount: repoData.open_issues_count ?? 0, + pullRequestsCount: prsCount, + contributorCount: contributorsCount, + repository: `${owner}/${repo}`, + }; + } catch (err: any) { + if (err.status === 401) { + if (this.hasToken && !this.tokenInvalid) { + this.resetToUnauthenticated(); + return this.getMetrics(owner, repo); + } + throw new Error(`GitHub authentication failed: ${err.message}`); + } + if (err.status === 404) { + throw new Error( + `Repository "${owner}/${repo}" not found — ensure it is public and the format is owner/repo.` + ); + } + if (err.status === 403) { + const resetHeader = err.response?.headers?.["x-ratelimit-reset"]; + let msg = "GitHub API rate limit exceeded."; + if (!this.hasToken) { + msg += + " Pass --github-token or set GITHUB_TOKEN for 5,000 requests/hour."; + } + if (resetHeader) { + msg += ` Resets at: ${new Date( + parseInt(resetHeader) * 1000 + ).toLocaleString()}`; + } + throw new Error(msg); + } + throw new Error(`Failed to fetch GitHub metrics: ${err.message}`); + } + } +} diff --git a/src/devmetrics/services/rootstockService.ts b/src/devmetrics/services/rootstockService.ts new file mode 100644 index 0000000..4abea3b --- /dev/null +++ b/src/devmetrics/services/rootstockService.ts @@ -0,0 +1,371 @@ +import { createPublicClient, http, isAddress } from "viem"; +import { rootstock, rootstockTestnet } from "viem/chains"; +import type { RootstockMetrics } from "../types.js"; + +export type Network = "mainnet" | "testnet"; + +/** Minimal shape of a full transaction object returned by viem getBlock. */ +type MinTx = { + to: `0x${string}` | null; + hash: `0x${string}`; +}; + +export class RootstockMetricsService { + // Typed loosely so we can switch chains without complex generics. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly client: any; + private readonly network: Network; + private readonly rpcUrl: string; + private readonly TOTAL_TIMEOUT = 45_000; + + constructor(rpcUrl?: string, network: Network = "mainnet") { + this.network = network; + this.rpcUrl = rpcUrl ?? this.defaultRpcUrl(network); + const chain = network === "testnet" ? rootstockTestnet : rootstock; + this.client = createPublicClient({ + chain, + // Per-call timeout via the transport layer; no dangling promises. + transport: http(this.rpcUrl, { timeout: 8_000, retryCount: 1 }), + }); + } + + getNetwork(): Network { + return this.network; + } + + getRpcUrl(): string { + return this.rpcUrl; + } + + private defaultRpcUrl(network: Network): string { + return network === "testnet" + ? (process.env["ROOTSTOCK_TESTNET_RPC_URL"] ?? + "https://public-node.testnet.rsk.co") + : (process.env["ROOTSTOCK_MAINNET_RPC_URL"] ?? + "https://public-node.rsk.co"); + } + + /** Public entry point — adds an overall deadline on top of per-call timeouts. */ + async getMetrics(contractAddress: string): Promise { + return Promise.race([ + this.fetchMetrics(contractAddress), + new Promise((_, reject) => + setTimeout( + () => + reject( + new Error( + `Rootstock metrics timed out after ${this.TOTAL_TIMEOUT}ms` + ) + ), + this.TOTAL_TIMEOUT + ) + ), + ]); + } + + private async fetchMetrics(contractAddress: string): Promise { + if (!isAddress(contractAddress)) { + throw new Error(`Invalid contract address: ${contractAddress}`); + } + + const addr = contractAddress.toLowerCase() as `0x${string}`; + + let currentBlock: number; + try { + currentBlock = Number(await this.client.getBlockNumber()); + } catch { + return this.emptyMetrics( + contractAddress, + `Could not reach Rootstock ${this.network} RPC (${this.rpcUrl}). Check your connection or use --rpc-url.` + ); + } + + let latestCode: string | undefined; + try { + latestCode = await this.client.getBytecode({ + address: addr, + blockNumber: BigInt(currentBlock), + }); + } catch { + latestCode = undefined; + } + + if (!latestCode || latestCode === "0x") { + return this.emptyMetrics( + contractAddress, + `No contract found at this address on Rootstock ${this.network}. ` + + `Verify the address is correct and try --network testnet if it was deployed to testnet.` + ); + } + + const deploymentBlock = await this.findDeploymentBlock(addr, currentBlock); + + if (deploymentBlock === null) { + return { + contractAddress, + deploymentBlock: null, + totalTransactionCount: 0, + lastTransactionTimestamp: null, + gasUsagePatterns: { average: 0, min: 0, max: 0 }, + }; + } + + const [txCount, lastTx, gasPatterns] = await Promise.allSettled([ + this.estimateTransactionCount(addr, deploymentBlock, currentBlock), + this.findLastTransaction(addr, deploymentBlock, currentBlock), + this.sampleGasUsage(addr, deploymentBlock, currentBlock), + ]); + + return { + contractAddress, + deploymentBlock, + totalTransactionCount: + txCount.status === "fulfilled" ? txCount.value : 0, + lastTransactionTimestamp: + lastTx.status === "fulfilled" ? lastTx.value : null, + gasUsagePatterns: + gasPatterns.status === "fulfilled" + ? gasPatterns.value + : { average: 0, min: 0, max: 0 }, + }; + } + + // ─── Helpers ────────────────────────────────────────────────────────────────── + + private emptyMetrics(contractAddress: string, note: string): RootstockMetrics { + return { + contractAddress, + deploymentBlock: null, + totalTransactionCount: 0, + lastTransactionTimestamp: null, + gasUsagePatterns: { average: 0, min: 0, max: 0 }, + note, + }; + } + + // ─── Deployment block ──────────────────────────────────────────────────────── + + private async findDeploymentBlock( + address: `0x${string}`, + currentBlock: number + ): Promise { + try { + const latestCode: string | undefined = await this.client.getBytecode({ + address, + blockNumber: BigInt(currentBlock), + }); + if (!latestCode || latestCode === "0x") { + return null; + } + + // Find a no-code lower bound by stepping back exponentially. + let upperWithCode = currentBlock; + let step = 1; + let foundNoCode = false; + let lowerNoCode = -1; + while (step <= currentBlock) { + const probe = currentBlock - step; + if (probe < 0) break; + try { + const code: string | undefined = await this.client.getBytecode({ + address, + blockNumber: BigInt(probe), + }); + if (code && code !== "0x") { + upperWithCode = probe; + step *= 2; + continue; + } + foundNoCode = true; + lowerNoCode = probe; + break; + } catch { + break; + } + } + + const low = foundNoCode ? lowerNoCode + 1 : 0; + const high = upperWithCode; + return this.binarySearchDeployment(address, low, high); + } catch { + return null; + } + } + + private async binarySearchDeployment( + address: `0x${string}`, + low: number, + high: number + ): Promise { + let lo = low; + let hi = high; + + for (let i = 0; i < 14 && lo < hi; i++) { + const mid = Math.floor((lo + hi) / 2); + try { + const code: string | undefined = await this.client.getBytecode({ + address, + blockNumber: BigInt(mid), + }); + if (code && code !== "0x") { + hi = mid; + } else { + lo = mid + 1; + } + } catch { + // If a specific block fails, assume code was absent and move forward. + lo = mid + 1; + } + } + + return lo; + } + + // ─── Transaction count (sampling-based estimate) ───────────────────────────── + + private async estimateTransactionCount( + address: `0x${string}`, + deploymentBlock: number, + currentBlock: number + ): Promise { + try { + const searchEnd = currentBlock; + const totalBlocks = searchEnd - deploymentBlock + 1; + const sampleSize = Math.min(30, totalBlocks); + const step = Math.max(1, Math.floor(totalBlocks / sampleSize)); + + let count = 0; + let samplesChecked = 0; + + for ( + let block = deploymentBlock; + block <= searchEnd && samplesChecked < sampleSize; + block += step + ) { + try { + const b = await this.client.getBlock({ + blockNumber: BigInt(block), + includeTransactions: true, + }); + if (b?.transactions) { + for (const tx of b.transactions as MinTx[]) { + if (tx?.to?.toLowerCase() === address) count++; + } + } + samplesChecked++; + } catch { + continue; + } + } + + if (samplesChecked > 0 && step > 1) { + return Math.round((count / samplesChecked) * totalBlocks); + } + return count; + } catch { + return 0; + } + } + + // ─── Last transaction timestamp ─────────────────────────────────────────────── + + private async findLastTransaction( + address: `0x${string}`, + deploymentBlock: number, + currentBlock: number + ): Promise { + try { + // Search a wider recent window to avoid false N/A values. + const searchFloor = Math.max(deploymentBlock, currentBlock - 50_000); + const maxChecks = 80; + const span = Math.max(1, currentBlock - searchFloor); + const step = Math.max(1, Math.floor(span / maxChecks)); + + for (let i = 0; i < maxChecks; i++) { + const block = currentBlock - i * step; + if (block < searchFloor) break; + + try { + const b = await this.client.getBlock({ + blockNumber: BigInt(block), + includeTransactions: true, + }); + if (b?.transactions) { + for (const tx of b.transactions as MinTx[]) { + if (tx?.to?.toLowerCase() === address) { + return new Date(Number(b.timestamp) * 1000).toISOString(); + } + } + } + } catch { + continue; + } + } + return null; + } catch { + return null; + } + } + + // ─── Gas usage patterns (from actual receipts — correct approach) ───────────── + + private async sampleGasUsage( + address: `0x${string}`, + deploymentBlock: number, + currentBlock: number + ): Promise<{ average: number; min: number; max: number }> { + const empty = { average: 0, min: 0, max: 0 }; + try { + const searchFloor = Math.max(deploymentBlock, currentBlock - 20_000); + const maxChecks = 80; + const maxSamples = 20; + const span = Math.max(1, currentBlock - searchFloor); + const step = Math.max(1, Math.floor(span / maxChecks)); + const gasUsages: number[] = []; + + for (let i = 0; i < maxChecks && gasUsages.length < maxSamples; i++) { + const block = currentBlock - i * step; + if (block < searchFloor) break; + + try { + const b = await this.client.getBlock({ + blockNumber: BigInt(block), + includeTransactions: true, + }); + if (!b?.transactions) continue; + + for (const tx of b.transactions as MinTx[]) { + if (gasUsages.length >= maxSamples) break; + if (tx?.to?.toLowerCase() !== address) continue; + + try { + // Fetch the receipt to get actual gasUsed — tx objects in blocks + // do NOT carry gasUsed; only receipts do. + const receipt = await this.client.getTransactionReceipt({ + hash: tx.hash, + }); + if (receipt?.gasUsed != null) { + gasUsages.push(Number(receipt.gasUsed)); + } + } catch { + continue; + } + } + } catch { + continue; + } + } + + if (gasUsages.length === 0) return empty; + + const sum = gasUsages.reduce((a, b) => a + b, 0); + return { + average: Math.round(sum / gasUsages.length), + min: Math.min(...gasUsages), + max: Math.max(...gasUsages), + }; + } catch { + return empty; + } + } +} diff --git a/src/devmetrics/types.ts b/src/devmetrics/types.ts new file mode 100644 index 0000000..0cf207e --- /dev/null +++ b/src/devmetrics/types.ts @@ -0,0 +1,42 @@ +export interface GitHubMetrics { + stars: number; + lastCommitDate: string | null; + openIssuesCount: number; + pullRequestsCount: number; + contributorCount: number; + repository: string; +} + +export interface RootstockMetrics { + contractAddress: string; + deploymentBlock: number | null; + totalTransactionCount: number; + lastTransactionTimestamp: string | null; + gasUsagePatterns: { + average: number; + min: number; + max: number; + }; + /** Human-readable note shown in output when data is partial (e.g. no bytecode found). */ + note?: string; +} + +export interface DevMetricsReport { + repository: string; + contractAddress: string; + github: GitHubMetrics; + rootstock: RootstockMetrics; + timestamp: string; +} + +export type OutputFormat = "table" | "json" | "markdown"; + +export interface DevMetricsOptions { + repos: string[]; + contracts: string[]; + format: OutputFormat; + ci: boolean; + githubToken?: string; + network: "mainnet" | "testnet"; + rpcUrl?: string; +} diff --git a/src/devmetrics/validation.ts b/src/devmetrics/validation.ts new file mode 100644 index 0000000..46370ab --- /dev/null +++ b/src/devmetrics/validation.ts @@ -0,0 +1,29 @@ +import type { OutputFormat } from "./types.js"; + +const REPO_RE = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/; +const ADDR_RE = /^0x[a-fA-F0-9]{40}$/; +const VALID_FORMATS: OutputFormat[] = ["table", "json", "markdown"]; + +export function validateRepo(repo: string): { valid: boolean; error?: string } { + if (!REPO_RE.test(repo)) { + return { + valid: false, + error: 'Repository must be in "owner/repo" format (e.g. rsksmart/rsk-cli)', + }; + } + return { valid: true }; +} + +export function validateContractAddress(address: string): { valid: boolean; error?: string } { + if (!ADDR_RE.test(address)) { + return { + valid: false, + error: "Contract address must start with 0x followed by 40 hex characters", + }; + } + return { valid: true }; +} + +export function validateOutputFormat(format: string): format is OutputFormat { + return VALID_FORMATS.includes(format as OutputFormat); +}