From cf5c53d9c93bcfefe7cf8ae22b77d05faef7bb27 Mon Sep 17 00:00:00 2001 From: lucifer1017 Date: Thu, 12 Feb 2026 18:25:40 +0530 Subject: [PATCH] added devmetrics cli feature --- README.md | 42 +++- bin/index.ts | 106 +++++++++ package-lock.json | 197 ++++++++++++++++ package.json | 2 + src/commands/devmetrics.ts | 236 +++++++++++++++++++ src/devmetrics/formatters/index.ts | 21 ++ src/devmetrics/formatters/json.ts | 6 + src/devmetrics/formatters/markdown.ts | 62 +++++ src/devmetrics/formatters/table.ts | 65 ++++++ src/devmetrics/githubService.ts | 214 +++++++++++++++++ src/devmetrics/rootstockService.ts | 321 ++++++++++++++++++++++++++ src/devmetrics/types.ts | 33 +++ src/devmetrics/validation.ts | 41 ++++ 13 files changed, 1345 insertions(+), 1 deletion(-) create mode 100644 src/commands/devmetrics.ts create mode 100644 src/devmetrics/formatters/index.ts create mode 100644 src/devmetrics/formatters/json.ts create mode 100644 src/devmetrics/formatters/markdown.ts create mode 100644 src/devmetrics/formatters/table.ts create mode 100644 src/devmetrics/githubService.ts create mode 100644 src/devmetrics/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 e2a94a7..309bf9f 100644 --- a/README.md +++ b/README.md @@ -851,7 +851,47 @@ The simulation provides comprehensive information: > **Note**: Simulation uses real blockchain state but does not execute transactions. It provides accurate estimates based on current network conditions. Gas prices may vary, so actual costs might differ slightly from simulation results. -### 12. RNS Resolve +### 12. Developer Metrics (GitHub + Rootstock) + +The `dev-metrics` command aggregates GitHub repository activity and Rootstock on-chain usage into a single developer health report. It supports table, JSON, and Markdown output formats and can be used both from the terminal and programmatically (for example via the MCP server integration). + +```bash +# Basic usage (mainnet) +rsk-cli dev-metrics \ + --repo owner/repo \ + --contract 0xYourContractAddress + +# Testnet +rsk-cli dev-metrics \ + --repo owner/repo \ + --contract 0xYourContractAddress \ + --testnet + +# JSON (CI/CD) +rsk-cli dev-metrics \ + --repo owner/repo \ + --contract 0xYourContractAddress \ + --format json + +# Markdown +rsk-cli dev-metrics \ + --repo owner/repo \ + --contract 0xYourContractAddress \ + --format markdown +``` + +Supported options: + +- `-r, --repo `: GitHub repository in `owner/repo` format (repeatable) +- `-c, --contract
`: Rootstock contract address (repeatable) +- `-f, --format `: `table`, `json`, or `markdown` (default: `table`) +- `--ci`: CI/CD mode, equivalent to JSON output +- `--github-token `: GitHub personal access token (or use the `GITHUB_TOKEN` environment variable) +- `-t, --testnet`: Use Rootstock testnet + +When used from the MCP server, the underlying command can be called in "external" mode to return structured data (`reports` and `errors`) without printing logs or spinners. + +### 13. RNS Resolve The `resolve` command allows you to interact with the RIF Name Service (RNS) on the Rootstock blockchain. You can perform both forward resolution (domain to address) and reverse resolution (address to domain name). diff --git a/bin/index.ts b/bin/index.ts index 517c695..98ba85f 100644 --- a/bin/index.ts +++ b/bin/index.ts @@ -19,6 +19,7 @@ import { configCommand } from "../src/commands/config.js"; import { transactionCommand } from "../src/commands/transaction.js"; import { monitorCommand, listMonitoringSessions, stopMonitoringSession } from "../src/commands/monitor.js"; import { simulateCommand, TransactionSimulationOptions } from "../src/commands/simulate.js"; +import { devmetricsCommand } from "../src/commands/devmetrics.js"; import { parseEther } from "viem"; import { resolveRNSToAddress } from "../src/utils/rnsHelper.js"; import { validateAndFormatAddressRSK } from "../src/utils/index.js"; @@ -485,4 +486,109 @@ program } }); +program + .command("dev-metrics") + .description( + "Aggregate GitHub and Rootstock on-chain data into a single developer health report", + ) + .option( + "-r, --repo ", + "GitHub repository in format owner/repo (can be used multiple times)", + ) + .option( + "-c, --contract
", + "Rootstock contract address (can be used multiple times)", + ) + .option( + "-f, --format ", + "Output format: table, json, or markdown", + "table", + ) + .option("--ci", "CI/CD mode: outputs JSON format", false) + .option("--github-token ", "GitHub personal access token") + .option("-t, --testnet", "Use Rootstock testnet network") + .action(async (options: any) => { + try { + const repos = Array.isArray(options.repo) + ? options.repo + : options.repo + ? [options.repo] + : []; + const contracts = Array.isArray(options.contract) + ? options.contract + : options.contract + ? [options.contract] + : []; + + if (repos.length === 0) { + console.error( + chalk.red("Error: At least one repository (--repo) is required"), + ); + return; + } + + if (contracts.length === 0) { + console.error( + chalk.red( + "Error: At least one contract address (--contract) is required", + ), + ); + return; + } + + let format: "table" | "json" | "markdown" = "table"; + if (options.ci) { + format = "json"; + } else if (options.format) { + const validFormats: Array<"table" | "json" | "markdown"> = [ + "table", + "json", + "markdown", + ]; + if (validFormats.includes(options.format)) { + format = options.format; + } else { + console.error( + chalk.red( + `Invalid format: ${options.format}. Must be one of: ${validFormats.join( + ", ", + )}`, + ), + ); + return; + } + } + + const { reports, errors } = await devmetricsCommand({ + repos, + contracts, + format, + ci: !!options.ci, + githubToken: options.githubToken, + testnet: !!options.testnet, + isExternal: false, + }); + + if (errors.length > 0) { + if (format === "json" || options.ci) { + console.error(JSON.stringify({ errors }, null, 2)); + } else { + console.error(chalk.red("\nāŒ Errors encountered:")); + errors.forEach(({ repo, contract, error }) => { + console.error(chalk.red(` ${repo} / ${contract}: ${error}`)); + }); + } + if (reports.length === 0) { + process.exit(1); + } + } + } catch (error: any) { + console.error( + chalk.red("Error during dev-metrics:"), + error?.message || error, + ); + process.exit(1); + } + }); + program.parse(process.argv); diff --git a/package-lock.json b/package-lock.json index 2b35a6d..3471c4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.4.0", "license": "MIT", "dependencies": { + "@octokit/rest": "^22.0.1", "@openzeppelin/contracts": "^5.0.2", "@rsksmart/rns-resolver.js": "^1.1.0", "@rsksmart/rsk-precompiled-abis": "^6.0.0-ARROWHEAD", @@ -23,6 +24,7 @@ "ora": "^8.0.1", "uuid": "^9.0.1", "viem": "^2.19.4", + "zod": "^3.25.0", "zxcvbn": "^4.4.2" }, "bin": { @@ -405,6 +407,161 @@ "url": "https://paulmillr.com/funding/" } }, + "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.2", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz", + "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==", + "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.7", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.7.tgz", + "integrity": "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.2", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "fast-content-type-parse": "^3.0.0", + "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.1.0", "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-5.1.0.tgz", @@ -617,6 +774,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", @@ -840,6 +1003,22 @@ "node": ">=4" } }, + "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.8.0", "resolved": "https://registry.npmjs.org/figlet/-/figlet-1.8.0.tgz", @@ -1517,6 +1696,7 @@ "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1531,6 +1711,12 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "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", @@ -1608,6 +1794,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -1636,6 +1823,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zxcvbn": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", diff --git a/package.json b/package.json index 7bd95da..a8c92a3 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "typescript": "^5.0.0" }, "dependencies": { + "@octokit/rest": "^22.0.1", "@openzeppelin/contracts": "^5.0.2", "@rsksmart/rns-resolver.js": "^1.1.0", "@rsksmart/rsk-precompiled-abis": "^6.0.0-ARROWHEAD", @@ -60,6 +61,7 @@ "ora": "^8.0.1", "uuid": "^9.0.1", "viem": "^2.19.4", + "zod": "^3.25.0", "zxcvbn": "^4.4.2" } } diff --git a/src/commands/devmetrics.ts b/src/commands/devmetrics.ts new file mode 100644 index 0000000..7539bc0 --- /dev/null +++ b/src/commands/devmetrics.ts @@ -0,0 +1,236 @@ +import chalk from "chalk"; +import ora from "ora"; +import { Address } from "viem"; +import { GitHubDevMetricsService } from "../devmetrics/githubService.js"; +import { RootstockDevMetricsService } from "../devmetrics/rootstockService.js"; +import { + DevMetricsReport, + OutputFormat, +} from "../devmetrics/types.js"; +import { + validateRepo as validateRepoInput, + validateContractAddress as validateContractInput, +} from "../devmetrics/validation.js"; +import { formatDevMetricsReport } from "../devmetrics/formatters/index.js"; + +type DevMetricsCommandOptions = { + repos: string[]; + contracts: string[]; + format: OutputFormat; + ci?: boolean; + githubToken?: string; + testnet?: boolean; + isExternal?: boolean; +}; + +type LogColor = (msg: string) => string; + +function logMessage( + opts: DevMetricsCommandOptions, + message: string, + color: LogColor = (m) => m, +) { + if (!opts.isExternal) { + console.log(color(message)); + } +} + +export async function devmetricsCommand( + options: DevMetricsCommandOptions, +): Promise<{ reports: DevMetricsReport[]; errors: Array<{ repo: string; contract: string; error: string }> }> { + const outputFormat: OutputFormat = options.format; + + const pairs: Array<{ repo: string; contract: string }> = []; + const repos = options.repos; + const contracts = options.contracts; + + if (repos.length === contracts.length) { + for (let i = 0; i < repos.length; i++) { + pairs.push({ repo: repos[i], contract: contracts[i] }); + } + } else if (contracts.length === 1) { + for (const repo of repos) { + pairs.push({ repo, contract: contracts[0] }); + } + } else if (repos.length === 1) { + for (const contract of contracts) { + pairs.push({ repo: repos[0], contract }); + } + } else { + throw new Error( + "Number of repositories and contracts must match, or one must be singular", + ); + } + + const validationErrors: string[] = []; + + for (const pair of pairs) { + const repoValidation = validateRepoInput(pair.repo); + if (!repoValidation.valid) { + validationErrors.push( + `Repository "${pair.repo}": ${repoValidation.error ?? "Invalid format"}`, + ); + } + + const contractValidation = validateContractInput(pair.contract); + if (!contractValidation.valid) { + validationErrors.push( + `Contract "${pair.contract}": ${contractValidation.error ?? "Invalid format"}`, + ); + } + } + + if (validationErrors.length > 0) { + const errorText = ["Validation errors:", ...validationErrors.map((e) => ` - ${e}`)].join( + "\n", + ); + throw new Error(errorText); + } + + const githubService = new GitHubDevMetricsService(options.githubToken); + const rootstockService = new RootstockDevMetricsService(!!options.testnet); + + if (outputFormat === "table") { + logMessage( + options, + chalk.cyan( + `🌐 Rootstock Network: ${chalk.bold( + options.testnet ? "TESTNET" : "MAINNET", + )}`, + ), + ); + logMessage( + options, + chalk.gray( + ` (RPC URL managed by existing rsk-cli ViemProvider configuration)\n`, + ), + ); + } + + const initialAuthStatus = githubService.isAuthenticated(); + + if (!initialAuthStatus && outputFormat === "table") { + logMessage( + options, + chalk.yellow( + "\nāš ļø No GitHub token detected. Using unauthenticated mode (60 requests/hour limit).", + ), + ); + logMessage( + options, + chalk.yellow( + " For 5,000 requests/hour, set a GITHUB_TOKEN environment variable.\n", + ), + ); + } + + if (outputFormat === "table") { + try { + const rateLimit = await githubService.getRateLimitStatus(); + if (rateLimit) { + const percentage = ((rateLimit.remaining / rateLimit.limit) * 100).toFixed(1); + const color = + rateLimit.remaining < rateLimit.limit * 0.1 + ? chalk.red + : rateLimit.remaining < rateLimit.limit * 0.3 + ? chalk.yellow + : chalk.green; + logMessage( + options, + color( + `šŸ“Š GitHub API: ${rateLimit.remaining}/${rateLimit.limit} requests remaining (${percentage}%)`, + ), + ); + if (rateLimit.remaining < rateLimit.limit * 0.2) { + logMessage( + options, + chalk.yellow( + ` Rate limit resets at: ${rateLimit.resetAt.toLocaleString()}`, + ), + ); + } + } + } catch { + // ignore rate limit fetch errors + } + } + + const reports: DevMetricsReport[] = []; + const errors: Array<{ repo: string; contract: string; error: string }> = []; + + for (const pair of pairs) { + try { + if (outputFormat === "table") { + logMessage( + options, + chalk.blue(`\nšŸ“Š Fetching metrics for ${pair.repo}...`), + ); + } + + const [owner, repo] = pair.repo.split("/"); + + let githubMetrics; + try { + if (outputFormat === "table") { + if (!options.isExternal) { + process.stdout.write(chalk.gray(" Fetching GitHub data... ")); + } + } + githubMetrics = await githubService.getMetrics(owner, repo); + if (outputFormat === "table") { + logMessage(options, chalk.green("āœ“")); + } + } catch (error: any) { + if (outputFormat === "table") { + logMessage(options, chalk.red("āœ—")); + } + throw error; + } + + let rootstockMetrics; + try { + if (outputFormat === "table") { + if (!options.isExternal) { + process.stdout.write(chalk.gray(" Fetching Rootstock data... ")); + } + } + rootstockMetrics = await rootstockService.getMetrics( + pair.contract, + !!options.testnet, + ); + if (outputFormat === "table") { + logMessage(options, chalk.green("āœ“")); + } + } catch (error: any) { + if (outputFormat === "table") { + logMessage(options, chalk.red("āœ—")); + } + throw error; + } + + const report: DevMetricsReport = { + repository: pair.repo, + contractAddress: rootstockMetrics.contractAddress as Address, + github: githubMetrics, + rootstock: rootstockMetrics, + timestamp: new Date().toISOString(), + }; + + reports.push(report); + } catch (error: any) { + errors.push({ + repo: pair.repo, + contract: pair.contract, + error: error?.message || "Unknown error", + }); + } + } + + if (reports.length > 0 && !options.isExternal) { + const output = formatDevMetricsReport(reports, outputFormat); + console.log(output); + } + + return { reports, errors }; +} + diff --git a/src/devmetrics/formatters/index.ts b/src/devmetrics/formatters/index.ts new file mode 100644 index 0000000..1a7540c --- /dev/null +++ b/src/devmetrics/formatters/index.ts @@ -0,0 +1,21 @@ +import { DevMetricsReport, OutputFormat } from "../types.js"; +import { formatDevMetricsAsTable } from "./table.js"; +import { formatDevMetricsAsJSON } from "./json.js"; +import { formatDevMetricsAsMarkdown } from "./markdown.js"; + +export function formatDevMetricsReport( + reports: DevMetricsReport[], + format: OutputFormat, +): string { + switch (format) { + case "table": + return formatDevMetricsAsTable(reports); + case "json": + return formatDevMetricsAsJSON(reports); + case "markdown": + return formatDevMetricsAsMarkdown(reports); + default: + return formatDevMetricsAsTable(reports); + } +} + diff --git a/src/devmetrics/formatters/json.ts b/src/devmetrics/formatters/json.ts new file mode 100644 index 0000000..ad66cc7 --- /dev/null +++ b/src/devmetrics/formatters/json.ts @@ -0,0 +1,6 @@ +import { DevMetricsReport } from "../types.js"; + +export function formatDevMetricsAsJSON(reports: DevMetricsReport[]): string { + return JSON.stringify(reports, null, 2); +} + diff --git a/src/devmetrics/formatters/markdown.ts b/src/devmetrics/formatters/markdown.ts new file mode 100644 index 0000000..18eef80 --- /dev/null +++ b/src/devmetrics/formatters/markdown.ts @@ -0,0 +1,62 @@ +import { DevMetricsReport } from "../types.js"; + +export function formatDevMetricsAsMarkdown(reports: DevMetricsReport[]): string { + const output: string[] = []; + + for (const report of reports) { + output.push(`# Developer Health Report: ${report.repository}`); + output.push(""); + output.push(`**Contract Address:** \`${report.contractAddress}\``); + output.push(`**Generated:** ${new Date(report.timestamp).toLocaleString()}`); + output.push(""); + + output.push("## šŸ“Š GitHub Metrics"); + output.push(""); + output.push("| Metric | Value |"); + output.push("|--------|-------|"); + output.push(`| ⭐ Stars | ${report.github.stars} |`); + output.push( + `| šŸ“ Last Commit | ${ + report.github.lastCommitDate + ? new Date(report.github.lastCommitDate).toLocaleDateString() + : "N/A" + } |`, + ); + output.push(`| šŸ› Open Issues | ${report.github.openIssuesCount} |`); + output.push(`| šŸ”€ Open PRs | ${report.github.pullRequestsCount} |`); + output.push(`| šŸ‘„ Contributors | ${report.github.contributorCount} |`); + output.push(""); + + output.push("## ā›“ļø Rootstock Metrics"); + output.push(""); + output.push("| Metric | Value |"); + output.push("|--------|-------|"); + output.push(`| šŸ“¦ Deployment Block | ${report.rootstock.deploymentBlock ?? "N/A"} |`); + output.push(`| šŸ“Š Total Transactions | ${report.rootstock.totalTransactionCount} |`); + output.push( + `| ā° Last Transaction | ${ + report.rootstock.lastTransactionTimestamp + ? new Date(report.rootstock.lastTransactionTimestamp).toLocaleDateString() + : "N/A" + } |`, + ); + output.push( + `| ⛽ Average Gas Usage | ${report.rootstock.gasUsagePatterns.average.toLocaleString()} |`, + ); + output.push( + `| ⛽ Min Gas Usage | ${report.rootstock.gasUsagePatterns.min.toLocaleString()} |`, + ); + output.push( + `| ⛽ Max Gas Usage | ${report.rootstock.gasUsagePatterns.max.toLocaleString()} |`, + ); + output.push(""); + + if (reports.length > 1) { + output.push("---"); + output.push(""); + } + } + + return output.join("\n"); +} + diff --git a/src/devmetrics/formatters/table.ts b/src/devmetrics/formatters/table.ts new file mode 100644 index 0000000..0c9d0a8 --- /dev/null +++ b/src/devmetrics/formatters/table.ts @@ -0,0 +1,65 @@ +import Table from "cli-table3"; +import chalk from "chalk"; +import { DevMetricsReport } from "../types.js"; + +export function formatDevMetricsAsTable(reports: DevMetricsReport[]): string { + const output: string[] = []; + + for (const report of reports) { + output.push(chalk.bold.cyan(`\nšŸ“Š Report for ${report.repository}`)); + output.push(chalk.gray(`Contract: ${report.contractAddress}`)); + output.push(chalk.gray(`Generated: ${new Date(report.timestamp).toLocaleString()}\n`)); + + const githubTable = new Table({ + head: [chalk.blue("GitHub Metrics"), chalk.yellow("Value")], + style: { head: [], border: [] }, + }); + + githubTable.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())], + ); + + output.push(githubTable.toString()); + + const rootstockTable = new Table({ + head: [chalk.blue("Rootstock Metrics"), chalk.yellow("Value")], + style: { head: [], border: [] }, + }); + + rootstockTable.push( + [ + "šŸ“¦ Deployment Block", + report.rootstock.deploymentBlock + ? chalk.white(report.rootstock.deploymentBlock.toString()) + : chalk.red("N/A"), + ], + [ + "šŸ“Š Total Transactions", + chalk.white(report.rootstock.totalTransactionCount.toString()), + ], + [ + "ā° Last Transaction", + report.rootstock.lastTransactionTimestamp + ? chalk.green(new Date(report.rootstock.lastTransactionTimestamp).toLocaleDateString()) + : chalk.red("N/A"), + ], + ["⛽ Avg Gas Usage", chalk.white(report.rootstock.gasUsagePatterns.average.toLocaleString())], + ["⛽ Min Gas Usage", chalk.white(report.rootstock.gasUsagePatterns.min.toLocaleString())], + ["⛽ Max Gas Usage", chalk.white(report.rootstock.gasUsagePatterns.max.toLocaleString())], + ); + + output.push("\n" + rootstockTable.toString()); + } + + return output.join("\n"); +} + diff --git a/src/devmetrics/githubService.ts b/src/devmetrics/githubService.ts new file mode 100644 index 0000000..c19e746 --- /dev/null +++ b/src/devmetrics/githubService.ts @@ -0,0 +1,214 @@ +import { Octokit } from "@octokit/rest"; +import { GitHubMetrics } from "./types.js"; + +/** + * Lightweight GitHub service used by the dev-metrics command. + * Mirrors the behavior of the standalone devmetrics project but is self-contained + * and does not perform any logging or spinner management (that is done in commands). + */ +export class GitHubDevMetricsService { + private octokit: Octokit; + private hasToken: boolean; + private tokenInvalid = false; + private readonly API_TIMEOUT = 15_000; // 15 seconds per API call + private readonly TOTAL_TIMEOUT = 60_000; // 60 seconds total for all GitHub calls + + constructor(token?: string) { + const authToken = token || process.env.GITHUB_TOKEN; + this.hasToken = !!authToken; + this.octokit = new Octokit({ + auth: authToken, + request: { + timeout: this.API_TIMEOUT, + }, + }); + } + + private async withTimeout(promise: Promise, timeoutMs: number = this.API_TIMEOUT): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`GitHub API call timed out after ${timeoutMs}ms`)), timeoutMs), + ), + ]); + } + + private resetToUnauthenticated() { + if (this.tokenInvalid) return; + this.tokenInvalid = true; + this.hasToken = false; + this.octokit = new Octokit(); + } + + isAuthenticated(): boolean { + return this.hasToken; + } + + 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 (error: any) { + if (error?.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 fetch 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: issues } = await this.withTimeout( + this.octokit.issues.listForRepo({ + owner, + repo, + state: "open", + per_page: 1, + }), + ); + + const { data: pullRequests } = await this.withTimeout( + this.octokit.pulls.list({ + owner, + repo, + state: "open", + per_page: 1, + }), + ); + + const issuesCount = repoData.open_issues_count || 0; + + 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 || 0; + } catch { + prsCount = pullRequests.length > 0 ? pullRequests.length : 0; + } + } else { + prsCount = pullRequests.length > 0 ? pullRequests.length : 0; + } + + let contributorsCount = 0; + try { + if (this.hasToken) { + const { data: allContributors } = await this.withTimeout( + this.octokit.repos.listContributors({ + owner, + repo, + per_page: 100, + }), + ); + contributorsCount = allContributors.length; + } else { + const { data: contributors } = await this.withTimeout( + this.octokit.repos.listContributors({ + owner, + repo, + per_page: 30, + }), + ); + contributorsCount = contributors.length; + } + } catch { + contributorsCount = 0; + } + + return { + stars: repoData.stargazers_count || 0, + lastCommitDate: commits[0]?.commit.committer?.date || null, + openIssuesCount: issuesCount, + pullRequestsCount: prsCount, + contributorCount: contributorsCount, + repository: `${owner}/${repo}`, + }; + } catch (error: any) { + if (error?.status === 401) { + if (this.hasToken && !this.tokenInvalid) { + this.resetToUnauthenticated(); + try { + return await this.getMetrics(owner, repo); + } catch (retryError: any) { + throw new Error( + `GitHub token is invalid or expired. Falling back to unauthenticated mode (60 requests/hour limit). Original error: ${retryError?.message || error.message}`, + ); + } + } + throw new Error(`GitHub API authentication failed: ${error.message}`); + } + + if (error?.status === 404) { + throw new Error(`Repository ${owner}/${repo} not found`); + } + + if (error?.status === 403) { + const resetTime = error.response?.headers?.["x-ratelimit-reset"]; + let message = "GitHub API rate limit exceeded."; + + if (this.hasToken && !this.tokenInvalid) { + message += " You have a token configured but still hit the limit (5,000/hour)."; + } else { + message += " Without a token, you are limited to 60 requests/hour."; + message += " Add a valid GITHUB_TOKEN environment variable for 5,000 requests/hour."; + } + + if (resetTime) { + const resetDate = new Date(parseInt(resetTime, 10) * 1000); + message += ` Rate limit resets at: ${resetDate.toLocaleString()}`; + } + + throw new Error(message); + } + + throw new Error(`Failed to fetch GitHub metrics: ${error?.message || String(error)}`); + } + } +} + diff --git a/src/devmetrics/rootstockService.ts b/src/devmetrics/rootstockService.ts new file mode 100644 index 0000000..fdc7971 --- /dev/null +++ b/src/devmetrics/rootstockService.ts @@ -0,0 +1,321 @@ +import { Address, PublicClient } from "viem"; +import ViemProvider from "../utils/viemProvider.js"; +import { RootstockMetrics } from "./types.js"; +import { validateAndFormatAddressRSK } from "../utils/index.js"; + +/** + * Rootstock metrics service for dev-metrics. + * Reimplements the ethers-based logic from the standalone devmetrics project + * using the existing ViemProvider used across rsk-cli. + */ +export class RootstockDevMetricsService { + private provider: ViemProvider; + private clientPromise: Promise; + private readonly RPC_TIMEOUT = 5_000; // per RPC call + private readonly MAX_DEPLOYMENT_SEARCH_BLOCKS = 10_000; + private readonly MAX_TRANSACTION_SEARCH_BLOCKS = 2_000; + + constructor(testnet: boolean) { + this.provider = new ViemProvider(testnet); + this.clientPromise = this.provider.getPublicClient(); + } + + private async getClient(): Promise { + return this.clientPromise; + } + + private async withTimeout(promise: Promise, timeoutMs: number = this.RPC_TIMEOUT): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`RPC call timed out after ${timeoutMs}ms`)), timeoutMs), + ), + ]); + } + + async getMetrics(contract: string, testnet: boolean): Promise { + const TOTAL_TIMEOUT = 45_000; + + const formatted = validateAndFormatAddressRSK(contract, testnet); + if (!formatted) { + throw new Error(`Invalid contract address: ${contract}`); + } + + const contractAddress = formatted as Address; + + return Promise.race([ + this.fetchMetrics(contractAddress), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`Rootstock metrics fetch timed out after ${TOTAL_TIMEOUT}ms`)), + TOTAL_TIMEOUT, + ), + ), + ]); + } + + private async fetchMetrics(contractAddress: Address): Promise { + const client = await this.getClient(); + + try { + const currentBlock = Number(await this.withTimeout(client.getBlockNumber(), 5_000)); + const deploymentBlock = await this.getDeploymentBlock(client, contractAddress, currentBlock); + + if (deploymentBlock !== null) { + const [txCountResult, lastTxResult, gasPatternsResult] = await Promise.allSettled([ + this.getTransactionCount(client, contractAddress, deploymentBlock), + this.getLastTransaction(client, contractAddress, deploymentBlock, currentBlock), + this.getGasUsagePatterns(client, contractAddress, deploymentBlock, currentBlock), + ]); + + return { + contractAddress, + deploymentBlock, + totalTransactionCount: txCountResult.status === "fulfilled" ? txCountResult.value : 0, + lastTransactionTimestamp: lastTxResult.status === "fulfilled" ? lastTxResult.value : null, + gasUsagePatterns: + gasPatternsResult.status === "fulfilled" + ? gasPatternsResult.value + : { average: 0, min: 0, max: 0 }, + }; + } + + return { + contractAddress, + deploymentBlock: null, + totalTransactionCount: 0, + lastTransactionTimestamp: null, + gasUsagePatterns: { average: 0, min: 0, max: 0 }, + }; + } catch (error: any) { + throw new Error(`Failed to fetch Rootstock metrics: ${error?.message || String(error)}`); + } + } + + private async getDeploymentBlock( + client: PublicClient, + contractAddress: Address, + currentBlock: number, + ): Promise { + try { + const searchStartBlock = Math.max(0, currentBlock - this.MAX_DEPLOYMENT_SEARCH_BLOCKS); + const latestCode = await this.withTimeout( + client.getBytecode({ address: contractAddress, blockNumber: BigInt(currentBlock) }), + 5_000, + ); + + if (!latestCode || latestCode === "0x") { + const chunkSize = 2_000; + const maxChecks = 10; + + for (let i = 0; i < maxChecks; i++) { + const block = currentBlock - i * chunkSize; + if (block < searchStartBlock) break; + + try { + const code = await this.withTimeout( + client.getBytecode({ address: contractAddress, blockNumber: BigInt(block) }), + 3_000, + ); + if (code && code !== "0x") { + return block; + } + } catch { + continue; + } + } + return null; + } + + return await this.binarySearchDeployment(client, contractAddress, searchStartBlock, currentBlock); + } catch { + return null; + } + } + + private async binarySearchDeployment( + client: PublicClient, + contractAddress: Address, + low: number, + high: number, + ): Promise { + const maxIterations = 10; + let iterations = 0; + + while (low < high && iterations < maxIterations) { + iterations++; + const mid = Math.floor((low + high) / 2); + + try { + const code = await this.withTimeout( + client.getBytecode({ address: contractAddress, blockNumber: BigInt(mid) }), + 3_000, + ); + + if (code && code !== "0x") { + high = mid; + } else { + low = mid + 1; + } + } catch { + return low; + } + } + + return low; + } + + private async getTransactionCount( + client: PublicClient, + contractAddress: Address, + deploymentBlock: number, + ): Promise { + try { + const currentBlock = Number(await this.withTimeout(client.getBlockNumber(), 5_000)); + const searchEndBlock = Math.min(currentBlock, deploymentBlock + this.MAX_TRANSACTION_SEARCH_BLOCKS); + + const sampleSize = 20; + const step = Math.max(1, Math.floor((searchEndBlock - deploymentBlock) / sampleSize)); + let count = 0; + let samplesChecked = 0; + + for ( + let block = deploymentBlock; + block <= searchEndBlock && samplesChecked < sampleSize; + block += step + ) { + try { + const blockData = await this.withTimeout( + client.getBlock({ blockNumber: BigInt(block), includeTransactions: true }), + 3_000, + ); + + const txs = blockData.transactions; + const txCount = (txs as any[]).filter((tx) => { + if (typeof tx === "string") return false; + return tx.to?.toLowerCase() === contractAddress.toLowerCase(); + }).length; + + count += txCount; + samplesChecked++; + } catch { + continue; + } + } + + if (samplesChecked > 0 && step > 1) { + const avgPerBlock = count / samplesChecked; + const totalBlocks = searchEndBlock - deploymentBlock + 1; + return Math.round(avgPerBlock * totalBlocks); + } + + return count; + } catch { + return 0; + } + } + + private async getLastTransaction( + client: PublicClient, + contractAddress: Address, + deploymentBlock: number, + currentBlock: number, + ): Promise { + try { + const searchStep = 100; + const maxBlocksToCheck = 500; + const searchStartBlock = Math.max(deploymentBlock, currentBlock - maxBlocksToCheck); + const maxChecks = 20; + + for (let i = 0; i < maxChecks; i++) { + const block = currentBlock - i * searchStep; + if (block < searchStartBlock) break; + + try { + const blockData = await this.withTimeout( + client.getBlock({ blockNumber: BigInt(block), includeTransactions: true }), + 3_000, + ); + + const txs = blockData.transactions; + const relevantTx = (txs as any[]).find((tx) => { + if (typeof tx === "string") return false; + return tx.to?.toLowerCase() === contractAddress.toLowerCase(); + }); + + if (relevantTx) { + const timestamp = Number(blockData.timestamp); + return new Date(timestamp * 1000).toISOString(); + } + } catch { + continue; + } + } + + return null; + } catch { + return null; + } + } + + private async getGasUsagePatterns( + client: PublicClient, + contractAddress: Address, + deploymentBlock: number, + currentBlock: number, + ): Promise<{ average: number; min: number; max: number }> { + try { + const sampleSize = 20; + const gasUsages: number[] = []; + const searchStep = 50; + const maxBlocksToCheck = 200; + const maxChecks = 10; + const searchStartBlock = Math.max(deploymentBlock, currentBlock - maxBlocksToCheck); + + for (let i = 0; i < maxChecks && gasUsages.length < sampleSize; i++) { + const block = currentBlock - i * searchStep; + if (block < searchStartBlock) break; + + try { + const blockData = await this.withTimeout( + client.getBlock({ blockNumber: BigInt(block), includeTransactions: true }), + 3_000, + ); + + const txs = blockData.transactions; + const relevantTxs = (txs as any[]).filter((tx) => { + if (typeof tx === "string") return false; + return tx.to?.toLowerCase() === contractAddress.toLowerCase(); + }); + + for (const tx of relevantTxs) { + if (gasUsages.length >= sampleSize) break; + if (typeof tx !== "string" && (tx as any).gasUsed != null) { + const gasUsed = (tx as any).gasUsed; + const num = typeof gasUsed === "bigint" ? Number(gasUsed) : Number(gasUsed); + if (!Number.isNaN(num)) { + gasUsages.push(num); + } + } + } + } catch { + continue; + } + } + + if (gasUsages.length === 0) { + return { average: 0, min: 0, max: 0 }; + } + + const sum = gasUsages.reduce((a, b) => a + b, 0); + const average = Math.round(sum / gasUsages.length); + const min = Math.min(...gasUsages); + const max = Math.max(...gasUsages); + + return { average, min, max }; + } catch { + return { average: 0, min: 0, max: 0 }; + } + } +} + diff --git a/src/devmetrics/types.ts b/src/devmetrics/types.ts new file mode 100644 index 0000000..293ae9e --- /dev/null +++ b/src/devmetrics/types.ts @@ -0,0 +1,33 @@ +import { Address } from "viem"; + +export interface GitHubMetrics { + stars: number; + lastCommitDate: string | null; + openIssuesCount: number; + pullRequestsCount: number; + contributorCount: number; + repository: string; +} + +export interface RootstockMetrics { + contractAddress: Address; + deploymentBlock: number | null; + totalTransactionCount: number; + lastTransactionTimestamp: string | null; + gasUsagePatterns: { + average: number; + min: number; + max: number; + }; +} + +export interface DevMetricsReport { + repository: string; + contractAddress: Address; + github: GitHubMetrics; + rootstock: RootstockMetrics; + timestamp: string; +} + +export type OutputFormat = "table" | "json" | "markdown"; + diff --git a/src/devmetrics/validation.ts b/src/devmetrics/validation.ts new file mode 100644 index 0000000..e69f761 --- /dev/null +++ b/src/devmetrics/validation.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +// GitHub repo format: owner/repo +export const repoSchema = z + .string() + .regex(/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/, "Repository must be in format: owner/repo"); + +// Ethereum/Rootstock address format +export const contractAddressSchema = z + .string() + .regex( + /^0x[a-fA-F0-9]{40}$/, + "Contract address must be a valid Ethereum/Rootstock address (0x followed by 40 hex characters)", + ); + +export const outputFormatSchema = z.enum(["table", "json", "markdown"]); + +export function validateRepo(repo: string): { valid: boolean; error?: string } { + try { + repoSchema.parse(repo); + return { valid: true }; + } catch (error: any) { + if (error instanceof z.ZodError) { + return { valid: false, error: error.issues[0]?.message ?? "Invalid repository format" }; + } + return { valid: false, error: "Invalid repository format" }; + } +} + +export function validateContractAddress(address: string): { valid: boolean; error?: string } { + try { + contractAddressSchema.parse(address); + return { valid: true }; + } catch (error: any) { + if (error instanceof z.ZodError) { + return { valid: false, error: error.issues[0]?.message ?? "Invalid contract address format" }; + } + return { valid: false, error: "Invalid contract address format" }; + } +} +