From 854370163fc32f3001e2125a1dd176253b990166 Mon Sep 17 00:00:00 2001 From: kpj2006 <24ucs074@lnmiit.ac.in> Date: Sun, 5 Jul 2026 20:15:31 +0530 Subject: [PATCH 1/7] feat: add API validation script with Zod schemas and CI workflow integration --- .github/workflows/api-validation.yml | 35 ++ Makefile | 14 + package-lock.json | 83 ++++- package.json | 9 +- scripts/validate-api.js | 507 +++++++++++++++++++++++++++ src/services/queries.js | 70 ++++ 6 files changed, 715 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/api-validation.yml create mode 100644 Makefile create mode 100644 scripts/validate-api.js create mode 100644 src/services/queries.js diff --git a/.github/workflows/api-validation.yml b/.github/workflows/api-validation.yml new file mode 100644 index 0000000..9760ac5 --- /dev/null +++ b/.github/workflows/api-validation.yml @@ -0,0 +1,35 @@ +name: API & Schema Validation + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: api-validation-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run API & Schema Validation + run: npm run validate-api + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e7309f3 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +# Makefile for OrgExplorer API & Schema Validation + +.PHONY: install validate-api test + +# Install project and development dependencies +install: + npm install + +# Run the GraphQL and REST API schema validation script +validate-api: + npm run validate-api + +# Alias for validation (useful in generic test runner context) +test: validate-api diff --git a/package-lock.json b/package-lock.json index 29676f9..f300c73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,8 +18,12 @@ "tailwindcss": "^4.3.0" }, "devDependencies": { + "@octokit/graphql-schema": "^15.26.1", "@vitejs/plugin-react": "^4.3.1", - "vite": "^5.4.6" + "dotenv": "^17.4.2", + "graphql": "^17.0.2", + "vite": "^5.4.6", + "zod": "^4.4.3" } }, "node_modules/@babel/code-frame": { @@ -726,6 +730,27 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@octokit/graphql-schema": { + "version": "15.26.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql-schema/-/graphql-schema-15.26.1.tgz", + "integrity": "sha512-RFDC2MpRBd4AxSRvUeBIVeBU7ojN/SxDfALUd7iVYOSeEK3gZaqR2MGOysj4Zh2xj2RY5fQAUT+Oqq7hWTraMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graphql": "^16.0.0", + "graphql-tag": "^2.10.3" + } + }, + "node_modules/@octokit/graphql-schema/node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -2011,6 +2036,19 @@ "csstype": "^3.0.2" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.328", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", @@ -2124,6 +2162,32 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphql": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-17.0.2.tgz", + "integrity": "sha512-FRWbddMxfkjiB7z+aQDWIR+E34xo9I8c9mtK2RPv8PmMzKRvrdsreHL/Ui/TmwHJfhHChEtsFPyMHKI+xuarQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.0.0 || ^24.0.0 || ^25.0.0 || >=26.0.0" + } + }, + "node_modules/graphql-tag": { + "version": "2.12.7", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.7.tgz", + "integrity": "sha512-xnE/NFzy+0eIesvAsREJZ284zTl/wYuBAvpsFSDhRGRdRHdnE90M21Q3xAWyYInb0J756c6x0pIQ62+vtvOs1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -2824,6 +2888,13 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "devOptional": true, + "license": "0BSD" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2942,6 +3013,16 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 2734074..b0b8b89 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "validate-api": "node scripts/validate-api.js" }, "dependencies": { "@tailwindcss/vite": "^4.3.0", @@ -19,7 +20,11 @@ "tailwindcss": "^4.3.0" }, "devDependencies": { + "@octokit/graphql-schema": "^15.26.1", "@vitejs/plugin-react": "^4.3.1", - "vite": "^5.4.6" + "dotenv": "^17.4.2", + "graphql": "^17.0.2", + "vite": "^5.4.6", + "zod": "^4.4.3" } } diff --git a/scripts/validate-api.js b/scripts/validate-api.js new file mode 100644 index 0000000..34152f8 --- /dev/null +++ b/scripts/validate-api.js @@ -0,0 +1,507 @@ +import dotenv from 'dotenv'; +import { z } from 'zod'; +import { validate as validateGraphQL } from '@octokit/graphql-schema'; +import * as queries from '../src/services/queries.js'; + +dotenv.config(); + +const token = process.env.GITHUB_TOKEN || process.env.GITHUB_PAT || ''; + +// ANSI Colors for output formatting +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', + bold: '\x1b[1m' +}; + +// ========================================== +// Zod Schemas for GitHub REST API Responses +// ========================================== + +export const OrganizationSchema = z.object({ + login: z.string(), + name: z.string().nullable().optional(), + public_repos: z.number(), + avatar_url: z.string().url(), + description: z.string().nullable().optional(), +}); + +export const RepositorySchema = z.object({ + name: z.string(), + pushed_at: z.string(), // ISO date string or similar timestamp representation + open_issues_count: z.number(), + stargazers_count: z.number(), + forks_count: z.number(), + watchers_count: z.number(), + language: z.string().nullable().optional(), + license: z.object({ + key: z.string(), + name: z.string(), + spdx_id: z.string().nullable().optional(), + url: z.string().url().nullable().optional(), + node_id: z.string().optional(), + }).nullable().optional(), + archived: z.boolean(), + fork: z.boolean(), +}); + +export const ContributorSchema = z.object({ + login: z.string(), + avatar_url: z.string().url(), + contributions: z.number(), +}); + +export const IssueSchema = z.object({ + title: z.string(), + number: z.number(), + user: z.object({ + login: z.string(), + }).nullable().optional(), + created_at: z.string(), + updated_at: z.string(), + html_url: z.string().url().optional(), + pull_request: z.object({ + url: z.string().url().optional(), + html_url: z.string().url().optional(), + }).nullable().optional(), + state: z.string(), +}); + +export const RateLimitSchema = z.object({ + limit: z.number(), + remaining: z.number(), + reset: z.number(), + used: z.number(), +}); + +// ========================================== +// Zod Schemas for GitHub GraphQL Responses +// ========================================== + +export const GraphQLOrgDetailsSchema = z.object({ + data: z.object({ + organization: z.object({ + login: z.string(), + name: z.string().nullable().optional(), + description: z.string().nullable().optional(), + avatarUrl: z.string().url(), + repositories: z.object({ + totalCount: z.number(), + }), + }).nullable(), + }), +}); + +export const GraphQLOrgReposSchema = z.object({ + data: z.object({ + organization: z.object({ + repositories: z.object({ + pageInfo: z.object({ + hasNextPage: z.boolean(), + endCursor: z.string().nullable().optional(), + }), + nodes: z.array(z.object({ + name: z.string(), + pushedAt: z.string(), + stargazerCount: z.number(), + forkCount: z.number(), + watchers: z.object({ + totalCount: z.number(), + }), + primaryLanguage: z.object({ + name: z.string(), + }).nullable().optional(), + licenseInfo: z.object({ + key: z.string(), + name: z.string(), + }).nullable().optional(), + isArchived: z.boolean(), + isFork: z.boolean(), + })), + }), + }).nullable(), + }), +}); + +export const GraphQLRepoIssuesSchema = z.object({ + data: z.object({ + repository: z.object({ + issues: z.object({ + pageInfo: z.object({ + hasNextPage: z.boolean(), + endCursor: z.string().nullable().optional(), + }), + nodes: z.array(z.object({ + title: z.string(), + number: z.number(), + createdAt: z.string(), + updatedAt: z.string(), + state: z.string(), + url: z.string().url(), + author: z.object({ + login: z.string(), + }).nullable().optional(), + })), + }), + }).nullable(), + }), +}); + +// ========================================== +// Static Mock Data for Local Validation Tests +// ========================================== + +const mockFixtures = { + organization: { + login: 'AOSSIE-Org', + name: 'AOSSIE', + public_repos: 12, + avatar_url: 'https://avatars.githubusercontent.com/u/12345', + description: 'An open source organization' + }, + repository: { + name: 'OrgExplorer', + pushed_at: '2026-07-05T12:00:00Z', + open_issues_count: 5, + stargazers_count: 42, + forks_count: 10, + watchers_count: 42, + language: 'JavaScript', + license: { + key: 'gpl-3.0', + name: 'GNU General Public License v3.0' + }, + archived: false, + fork: false + }, + contributor: { + login: 'kpj2006', + avatar_url: 'https://avatars.githubusercontent.com/u/54321', + contributions: 150 + }, + issue: { + title: 'Fix sidebar responsiveness', + number: 101, + user: { + login: 'aossie_dev' + }, + created_at: '2026-07-01T10:00:00Z', + updated_at: '2026-07-04T15:00:00Z', + html_url: 'https://github.com/AOSSIE-Org/OrgExplorer/issues/101', + pull_request: null, + state: 'open' + }, + rateLimit: { + limit: 5000, + remaining: 4999, + reset: Math.floor(Date.now() / 1000) + 3600, + used: 1 + } +}; + +// ========================================== +// Helper Functions +// ========================================== + +async function fetchREST(url) { + const headers = { Accept: 'application/vnd.github.v3+json' }; + if (token) { + headers.Authorization = `token ${token}`; + } + const res = await fetch(url, { headers }); + if (res.status === 403) { + throw new Error(`REST Rate Limit or Forbidden for ${url}. Consider adding a GITHUB_TOKEN.`); + } + if (!res.ok) { + throw new Error(`REST Fetch failed for ${url}: ${res.status} ${res.statusText}`); + } + return res.json(); +} + +async function fetchGraphQL(query, variables = {}) { + const res = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + Authorization: `bearer ${token}`, + 'Content-Type': 'application/json', + 'User-Agent': 'OrgExplorer-Schema-Validator' + }, + body: JSON.stringify({ query, variables }), + }); + if (!res.ok) { + throw new Error(`GraphQL Fetch failed: ${res.status} ${res.statusText}`); + } + return res.json(); +} + +function handleZodError(error, contextName) { + console.error(`\n${colors.red}${colors.bold}❌ Validation failed in context: ${contextName}${colors.reset}`); + const issues = error.issues || error.errors || []; + issues.forEach(err => { + const fieldPath = err.path.join('.'); + console.error(` - Field: ${colors.cyan}${fieldPath}${colors.reset}`); + console.error(` Error: ${colors.yellow}${err.message}${colors.reset} (code: ${err.code})`); + }); +} + +// ========================================== +// Main Runner +// ========================================== + +async function main() { + let failed = false; + + console.log(`\n${colors.blue}${colors.bold}=== OrgExplorer Data Schema & API Validation ===${colors.reset}\n`); + + if (token) { + console.log(`${colors.green}🔑 GitHub token found. Running in authenticated mode (full integration checks).${colors.reset}`); + } else { + console.log(`${colors.yellow}⚠️ No GitHub token found. Running in unauthenticated mode (live GraphQL skipped, live REST may hit rate limits).${colors.reset}`); + } + + // ------------------------------------------ + // 1. Static GraphQL Query Syntax Validation + // ------------------------------------------ + console.log(`\n${colors.cyan}${colors.bold}Step 1: Statically validating GraphQL query syntax...${colors.reset}`); + + for (const [name, query] of Object.entries(queries)) { + console.log(` Validating ${colors.bold}${name}${colors.reset}...`); + const errors = validateGraphQL(query); + if (errors.length > 0) { + failed = true; + console.error(` ${colors.red}❌ ${name} is invalid:${colors.reset}`); + errors.forEach(err => console.error(` - ${err.message}`)); + } else { + console.log(` ${colors.green}✓ ${name} is syntactically valid against GitHub schema.${colors.reset}`); + } + } + + // ------------------------------------------ + // 2. Static Schema Validation (Mock Fixtures) + // ------------------------------------------ + console.log(`\n${colors.cyan}${colors.bold}Step 2: Statically validating schemas with mock fixtures...${colors.reset}`); + + const staticTests = [ + { name: 'OrganizationSchema', schema: OrganizationSchema, data: mockFixtures.organization }, + { name: 'RepositorySchema', schema: RepositorySchema, data: mockFixtures.repository }, + { name: 'ContributorSchema', schema: ContributorSchema, data: mockFixtures.contributor }, + { name: 'IssueSchema', schema: IssueSchema, data: mockFixtures.issue }, + { name: 'RateLimitSchema', schema: RateLimitSchema, data: mockFixtures.rateLimit } + ]; + + for (const test of staticTests) { + const result = test.schema.safeParse(test.data); + if (!result.success) { + failed = true; + handleZodError(result.error, `Static Mock - ${test.name}`); + } else { + console.log(` ${colors.green}✓ ${test.name} correctly parsed mock data.${colors.reset}`); + } + } + + // ------------------------------------------ + // 3. Live API Schema Validation (Integration) + // ------------------------------------------ + console.log(`\n${colors.cyan}${colors.bold}Step 3: Validating live GitHub API responses (detecting breaking changes)...${colors.reset}`); + + // Test target info + const testOrg = 'AOSSIE-Org'; + const testRepo = 'OrgExplorer'; + + // Live REST API checks + try { + console.log(` Fetching live Org details for ${colors.bold}${testOrg}${colors.reset}...`); + const liveOrg = await fetchREST(`https://api.github.com/orgs/${testOrg}`); + const orgParse = OrganizationSchema.safeParse(liveOrg); + if (!orgParse.success) { + failed = true; + handleZodError(orgParse.error, `Live REST - Organization: ${testOrg}`); + } else { + console.log(` ${colors.green}✓ Live REST organization schema matches expectations.${colors.reset}`); + } + } catch (err) { + if (!token && err.message.includes('403')) { + console.log(` ${colors.yellow}⚠ Skipped live Org REST fetch due to rate limiting (no token provided).${colors.reset}`); + } else { + failed = true; + console.error(` ${colors.red}❌ Error fetching live Org REST: ${err.message}${colors.reset}`); + } + } + + try { + console.log(` Fetching live repositories for ${colors.bold}${testOrg}${colors.reset}...`); + const liveRepos = await fetchREST(`https://api.github.com/orgs/${testOrg}/repos?per_page=5`); + if (Array.isArray(liveRepos)) { + let reposValid = true; + for (const repo of liveRepos) { + const repoParse = RepositorySchema.safeParse(repo); + if (!repoParse.success) { + failed = true; + reposValid = false; + handleZodError(repoParse.error, `Live REST - Repository: ${repo.name}`); + } + } + if (reposValid) { + console.log(` ${colors.green}✓ Live REST repositories schema matches expectations (checked ${liveRepos.length} repos).${colors.reset}`); + } + } else { + failed = true; + console.error(` ${colors.red}❌ Expected array of repos, got: ${typeof liveRepos}${colors.reset}`); + } + } catch (err) { + if (!token && err.message.includes('403')) { + console.log(` ${colors.yellow}⚠ Skipped live repos REST fetch due to rate limiting.${colors.reset}`); + } else { + failed = true; + console.error(` ${colors.red}❌ Error fetching live Repos REST: ${err.message}${colors.reset}`); + } + } + + try { + console.log(` Fetching live contributors for ${colors.bold}${testOrg}/${testRepo}${colors.reset}...`); + const liveContribs = await fetchREST(`https://api.github.com/repos/${testOrg}/${testRepo}/contributors?per_page=5`); + if (Array.isArray(liveContribs)) { + let contribsValid = true; + for (const c of liveContribs) { + const cParse = ContributorSchema.safeParse(c); + if (!cParse.success) { + failed = true; + contribsValid = false; + handleZodError(cParse.error, `Live REST - Contributor: ${c.login}`); + } + } + if (contribsValid) { + console.log(` ${colors.green}✓ Live REST contributors schema matches expectations (checked ${liveContribs.length} contribs).${colors.reset}`); + } + } else { + failed = true; + console.error(` ${colors.red}❌ Expected array of contributors, got: ${typeof liveContribs}${colors.reset}`); + } + } catch (err) { + if (!token && err.message.includes('403')) { + console.log(` ${colors.yellow}⚠ Skipped live contributors REST fetch due to rate limiting.${colors.reset}`); + } else { + failed = true; + console.error(` ${colors.red}❌ Error fetching live Contributors REST: ${err.message}${colors.reset}`); + } + } + + try { + console.log(` Fetching live issues for ${colors.bold}${testOrg}/${testRepo}${colors.reset}...`); + const liveIssues = await fetchREST(`https://api.github.com/repos/${testOrg}/${testRepo}/issues?per_page=5&state=all`); + if (Array.isArray(liveIssues)) { + let issuesValid = true; + for (const issue of liveIssues) { + const issueParse = IssueSchema.safeParse(issue); + if (!issueParse.success) { + failed = true; + issuesValid = false; + handleZodError(issueParse.error, `Live REST - Issue: #${issue.number}`); + } + } + if (issuesValid) { + console.log(` ${colors.green}✓ Live REST issues schema matches expectations (checked ${liveIssues.length} issues).${colors.reset}`); + } + } else { + failed = true; + console.error(` ${colors.red}❌ Expected array of issues, got: ${typeof liveIssues}${colors.reset}`); + } + } catch (err) { + if (!token && err.message.includes('403')) { + console.log(` ${colors.yellow}⚠ Skipped live issues REST fetch due to rate limiting.${colors.reset}`); + } else { + failed = true; + console.error(` ${colors.red}❌ Error fetching live Issues REST: ${err.message}${colors.reset}`); + } + } + + try { + console.log(` Fetching live rate limit status...`); + const liveRate = await fetchREST(`https://api.github.com/rate_limit`); + const rateParse = RateLimitSchema.safeParse(liveRate.rate); + if (!rateParse.success) { + failed = true; + handleZodError(rateParse.error, `Live REST - RateLimit`); + } else { + console.log(` ${colors.green}✓ Live REST rate limit schema matches expectations.${colors.reset}`); + } + } catch (err) { + if (!token && err.message.includes('403')) { + console.log(` ${colors.yellow}⚠ Skipped live rate limit REST fetch due to rate limiting.${colors.reset}`); + } else { + failed = true; + console.error(` ${colors.red}❌ Error fetching live RateLimit REST: ${err.message}${colors.reset}`); + } + } + + // Live GraphQL API checks + if (token) { + try { + console.log(` Running live GraphQL query: ${colors.bold}GET_ORG_DETAILS${colors.reset} for ${testOrg}...`); + const res = await fetchGraphQL(queries.GET_ORG_DETAILS, { login: testOrg }); + const parseResult = GraphQLOrgDetailsSchema.safeParse(res); + if (!parseResult.success) { + failed = true; + handleZodError(parseResult.error, 'Live GraphQL - GET_ORG_DETAILS'); + } else { + console.log(` ${colors.green}✓ Live GraphQL GET_ORG_DETAILS matches expectations.${colors.reset}`); + } + } catch (err) { + failed = true; + console.error(` ${colors.red}❌ Error in live GraphQL GET_ORG_DETAILS: ${err.message}${colors.reset}`); + } + + try { + console.log(` Running live GraphQL query: ${colors.bold}GET_ORG_REPOS${colors.reset} for ${testOrg}...`); + const res = await fetchGraphQL(queries.GET_ORG_REPOS, { login: testOrg, first: 3 }); + const parseResult = GraphQLOrgReposSchema.safeParse(res); + if (!parseResult.success) { + failed = true; + handleZodError(parseResult.error, 'Live GraphQL - GET_ORG_REPOS'); + } else { + console.log(` ${colors.green}✓ Live GraphQL GET_ORG_REPOS matches expectations.${colors.reset}`); + } + } catch (err) { + failed = true; + console.error(` ${colors.red}❌ Error in live GraphQL GET_ORG_REPOS: ${err.message}${colors.reset}`); + } + + try { + console.log(` Running live GraphQL query: ${colors.bold}GET_REPO_ISSUES${colors.reset} for ${testOrg}/${testRepo}...`); + const res = await fetchGraphQL(queries.GET_REPO_ISSUES, { owner: testOrg, name: testRepo, first: 3 }); + const parseResult = GraphQLRepoIssuesSchema.safeParse(res); + if (!parseResult.success) { + failed = true; + handleZodError(parseResult.error, 'Live GraphQL - GET_REPO_ISSUES'); + } else { + console.log(` ${colors.green}✓ Live GraphQL GET_REPO_ISSUES matches expectations.${colors.reset}`); + } + } catch (err) { + failed = true; + console.error(` ${colors.red}❌ Error in live GraphQL GET_REPO_ISSUES: ${err.message}${colors.reset}`); + } + } else { + console.log(`\n${colors.yellow}⚠️ Live GraphQL validation skipped: GITHUB_TOKEN or GITHUB_PAT not set.${colors.reset}`); + } + + // ------------------------------------------ + // Summary and Exit Code + // ------------------------------------------ + console.log(`\n${colors.bold}=== Validation Summary ===${colors.reset}`); + if (failed) { + console.error(`${colors.red}${colors.bold}❌ Schema / API Validation FAILED! Check error outputs above.${colors.reset}\n`); + process.exit(1); + } else { + console.log(`${colors.green}${colors.bold}✓ All GraphQL query syntax and API schemas validated successfully!${colors.reset}\n`); + process.exit(0); + } +} + +main().catch(err => { + console.error(`${colors.red}Fatal Error: ${err.message}${colors.reset}`); + process.exit(1); +}); diff --git a/src/services/queries.js b/src/services/queries.js new file mode 100644 index 0000000..73c88bc --- /dev/null +++ b/src/services/queries.js @@ -0,0 +1,70 @@ +// Reusable GitHub GraphQL query strings for OrgExplorer API validation + +export const GET_ORG_DETAILS = ` + query GetOrgDetails($login: String!) { + organization(login: $login) { + login + name + description + avatarUrl + repositories { + totalCount + } + } + } +`; + +export const GET_ORG_REPOS = ` + query GetOrgRepos($login: String!, $first: Int!, $after: String) { + organization(login: $login) { + repositories(first: $first, after: $after, orderBy: {field: PUSHED_AT, direction: DESC}) { + pageInfo { + hasNextPage + endCursor + } + nodes { + name + pushedAt + stargazerCount + forkCount + watchers { + totalCount + } + primaryLanguage { + name + } + licenseInfo { + key + name + } + isArchived + isFork + } + } + } + } +`; + +export const GET_REPO_ISSUES = ` + query GetRepoIssues($owner: String!, $name: String!, $first: Int!, $after: String) { + repository(owner: $owner, name: $name) { + issues(first: $first, after: $after, states: [OPEN, CLOSED]) { + pageInfo { + hasNextPage + endCursor + } + nodes { + title + number + createdAt + updatedAt + state + url + author { + login + } + } + } + } + } +`; From 0ec311b8b6967c6f0d33e05c0759759750713afb Mon Sep 17 00:00:00 2001 From: kpj2006 <24ucs074@lnmiit.ac.in> Date: Sun, 5 Jul 2026 21:10:26 +0530 Subject: [PATCH 2/7] Update .github/workflows/api-validation.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .github/workflows/api-validation.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/api-validation.yml b/.github/workflows/api-validation.yml index 9760ac5..51aad3a 100644 --- a/.github/workflows/api-validation.yml +++ b/.github/workflows/api-validation.yml @@ -1,5 +1,8 @@ name: API & Schema Validation +permissions: + contents: read + on: push: branches: @@ -19,6 +22,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@v4 From 8bf147f41a7edc3ba900f1334dfd1d6a2b6e205e Mon Sep 17 00:00:00 2001 From: kpj2006 <24ucs074@lnmiit.ac.in> Date: Tue, 7 Jul 2026 22:18:43 +0530 Subject: [PATCH 3/7] Update scripts/validate-api.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- scripts/validate-api.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/scripts/validate-api.js b/scripts/validate-api.js index 34152f8..5baa848 100644 --- a/scripts/validate-api.js +++ b/scripts/validate-api.js @@ -26,7 +26,7 @@ export const OrganizationSchema = z.object({ login: z.string(), name: z.string().nullable().optional(), public_repos: z.number(), - avatar_url: z.string().url(), + avatar_url: z.url(), description: z.string().nullable().optional(), }); @@ -42,7 +42,7 @@ export const RepositorySchema = z.object({ key: z.string(), name: z.string(), spdx_id: z.string().nullable().optional(), - url: z.string().url().nullable().optional(), + url: z.url().nullable().optional(), node_id: z.string().optional(), }).nullable().optional(), archived: z.boolean(), @@ -51,7 +51,7 @@ export const RepositorySchema = z.object({ export const ContributorSchema = z.object({ login: z.string(), - avatar_url: z.string().url(), + avatar_url: z.url(), contributions: z.number(), }); @@ -63,10 +63,10 @@ export const IssueSchema = z.object({ }).nullable().optional(), created_at: z.string(), updated_at: z.string(), - html_url: z.string().url().optional(), + html_url: z.url().optional(), pull_request: z.object({ - url: z.string().url().optional(), - html_url: z.string().url().optional(), + url: z.url().optional(), + html_url: z.url().optional(), }).nullable().optional(), state: z.string(), }); @@ -88,7 +88,7 @@ export const GraphQLOrgDetailsSchema = z.object({ login: z.string(), name: z.string().nullable().optional(), description: z.string().nullable().optional(), - avatarUrl: z.string().url(), + avatarUrl: z.url(), repositories: z.object({ totalCount: z.number(), }), @@ -141,7 +141,7 @@ export const GraphQLRepoIssuesSchema = z.object({ createdAt: z.string(), updatedAt: z.string(), state: z.string(), - url: z.string().url(), + url: z.url(), author: z.object({ login: z.string(), }).nullable().optional(), From 52d55fd821ca484bacaabea910baf4d456234a81 Mon Sep 17 00:00:00 2001 From: kpj2006 <24ucs074@lnmiit.ac.in> Date: Tue, 7 Jul 2026 22:29:42 +0530 Subject: [PATCH 4/7] feat: implement GitHub API schema validation with Zod and automated CI/CD workflow testing --- .github/workflows/api-validation.yml | 4 ++-- scripts/validate-api.js | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/api-validation.yml b/.github/workflows/api-validation.yml index 51aad3a..8bcd5db 100644 --- a/.github/workflows/api-validation.yml +++ b/.github/workflows/api-validation.yml @@ -21,12 +21,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 22 cache: npm diff --git a/scripts/validate-api.js b/scripts/validate-api.js index 34152f8..8b62d73 100644 --- a/scripts/validate-api.js +++ b/scripts/validate-api.js @@ -214,7 +214,9 @@ async function fetchREST(url) { } const res = await fetch(url, { headers }); if (res.status === 403) { - throw new Error(`REST Rate Limit or Forbidden for ${url}. Consider adding a GITHUB_TOKEN.`); + const error = new Error(`REST Rate Limit or Forbidden for ${url}. Consider adding a GITHUB_TOKEN.`); + error.status = 403; + throw error; } if (!res.ok) { throw new Error(`REST Fetch failed for ${url}: ${res.status} ${res.statusText}`); @@ -324,7 +326,7 @@ async function main() { console.log(` ${colors.green}✓ Live REST organization schema matches expectations.${colors.reset}`); } } catch (err) { - if (!token && err.message.includes('403')) { + if (!token && err.status === 403) { console.log(` ${colors.yellow}⚠ Skipped live Org REST fetch due to rate limiting (no token provided).${colors.reset}`); } else { failed = true; @@ -353,7 +355,7 @@ async function main() { console.error(` ${colors.red}❌ Expected array of repos, got: ${typeof liveRepos}${colors.reset}`); } } catch (err) { - if (!token && err.message.includes('403')) { + if (!token && err.status === 403) { console.log(` ${colors.yellow}⚠ Skipped live repos REST fetch due to rate limiting.${colors.reset}`); } else { failed = true; @@ -382,7 +384,7 @@ async function main() { console.error(` ${colors.red}❌ Expected array of contributors, got: ${typeof liveContribs}${colors.reset}`); } } catch (err) { - if (!token && err.message.includes('403')) { + if (!token && err.status === 403) { console.log(` ${colors.yellow}⚠ Skipped live contributors REST fetch due to rate limiting.${colors.reset}`); } else { failed = true; @@ -411,7 +413,7 @@ async function main() { console.error(` ${colors.red}❌ Expected array of issues, got: ${typeof liveIssues}${colors.reset}`); } } catch (err) { - if (!token && err.message.includes('403')) { + if (!token && err.status === 403) { console.log(` ${colors.yellow}⚠ Skipped live issues REST fetch due to rate limiting.${colors.reset}`); } else { failed = true; @@ -430,7 +432,7 @@ async function main() { console.log(` ${colors.green}✓ Live REST rate limit schema matches expectations.${colors.reset}`); } } catch (err) { - if (!token && err.message.includes('403')) { + if (!token && err.status === 403) { console.log(` ${colors.yellow}⚠ Skipped live rate limit REST fetch due to rate limiting.${colors.reset}`); } else { failed = true; From 9da5e801d816d4804d0cf386e0243dcbdcc4aa10 Mon Sep 17 00:00:00 2001 From: kpj2006 <24ucs074@lnmiit.ac.in> Date: Tue, 7 Jul 2026 22:36:34 +0530 Subject: [PATCH 5/7] feat: Refactored & Deduplicated Live REST Checks --- .github/workflows/build-matrix.yml | 40 ++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/build-matrix.yml diff --git a/.github/workflows/build-matrix.yml b/.github/workflows/build-matrix.yml new file mode 100644 index 0000000..75535b4 --- /dev/null +++ b/.github/workflows/build-matrix.yml @@ -0,0 +1,40 @@ +name: Build Matrix CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: build-matrix-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build (Node ${{ matrix.node-version }} on ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + node-version: [20, 22] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build application + run: npm run build From cbc39b4af270f9ffc7c222a78c00f52256ad3550 Mon Sep 17 00:00:00 2001 From: kpj2006 <24ucs074@lnmiit.ac.in> Date: Tue, 7 Jul 2026 22:37:31 +0530 Subject: [PATCH 6/7] feat: Refactored & Deduplicated Live REST Checks --- scripts/validate-api.js | 232 +++++++++++++++++++--------------------- 1 file changed, 111 insertions(+), 121 deletions(-) diff --git a/scripts/validate-api.js b/scripts/validate-api.js index 1f43cd9..4954953 100644 --- a/scripts/validate-api.js +++ b/scripts/validate-api.js @@ -250,12 +250,68 @@ function handleZodError(error, contextName) { }); } +async function validateLiveRESTCollection({ + url, + schema, + startMsg, + successMsg, + skipMsg, + errorMsg, + isArray = false, + getZodContext, + transform = (data) => data, + collectionTypeLabel = '' +}) { + try { + console.log(` ${startMsg}`); + const data = await fetchREST(url); + if (isArray) { + if (Array.isArray(data)) { + let valid = true; + for (const item of data) { + const parseResult = schema.safeParse(item); + if (!parseResult.success) { + failed = true; + valid = false; + handleZodError(parseResult.error, getZodContext(item)); + } + } + if (valid) { + const formattedSuccess = successMsg.replace('{count}', data.length); + console.log(` ${colors.green}✓ ${formattedSuccess}${colors.reset}`); + } + } else { + failed = true; + console.error(` ${colors.red}❌ Expected array of ${collectionTypeLabel}, got: ${typeof data}${colors.reset}`); + } + } else { + const targetData = transform(data); + const parseResult = schema.safeParse(targetData); + if (!parseResult.success) { + failed = true; + handleZodError(parseResult.error, getZodContext(data)); + } else { + console.log(` ${colors.green}✓ ${successMsg}${colors.reset}`); + } + } + } catch (err) { + if (!token && err.status === 403) { + console.log(` ${colors.yellow}⚠ ${skipMsg}${colors.reset}`); + } else { + failed = true; + console.error(` ${colors.red}❌ ${errorMsg}: ${err.message}${colors.reset}`); + } + } +} + // ========================================== // Main Runner // ========================================== +let failed = false; + async function main() { - let failed = false; + failed = false; console.log(`\n${colors.blue}${colors.bold}=== OrgExplorer Data Schema & API Validation ===${colors.reset}\n`); @@ -315,130 +371,64 @@ async function main() { const testRepo = 'OrgExplorer'; // Live REST API checks - try { - console.log(` Fetching live Org details for ${colors.bold}${testOrg}${colors.reset}...`); - const liveOrg = await fetchREST(`https://api.github.com/orgs/${testOrg}`); - const orgParse = OrganizationSchema.safeParse(liveOrg); - if (!orgParse.success) { - failed = true; - handleZodError(orgParse.error, `Live REST - Organization: ${testOrg}`); - } else { - console.log(` ${colors.green}✓ Live REST organization schema matches expectations.${colors.reset}`); - } - } catch (err) { - if (!token && err.status === 403) { - console.log(` ${colors.yellow}⚠ Skipped live Org REST fetch due to rate limiting (no token provided).${colors.reset}`); - } else { - failed = true; - console.error(` ${colors.red}❌ Error fetching live Org REST: ${err.message}${colors.reset}`); - } - } + await validateLiveRESTCollection({ + url: `https://api.github.com/orgs/${testOrg}`, + schema: OrganizationSchema, + startMsg: `Fetching live Org details for ${colors.bold}${testOrg}${colors.reset}...`, + successMsg: `Live REST organization schema matches expectations.`, + skipMsg: `Skipped live Org REST fetch due to rate limiting (no token provided).`, + errorMsg: `Error fetching live Org REST`, + isArray: false, + getZodContext: () => `Live REST - Organization: ${testOrg}` + }); - try { - console.log(` Fetching live repositories for ${colors.bold}${testOrg}${colors.reset}...`); - const liveRepos = await fetchREST(`https://api.github.com/orgs/${testOrg}/repos?per_page=5`); - if (Array.isArray(liveRepos)) { - let reposValid = true; - for (const repo of liveRepos) { - const repoParse = RepositorySchema.safeParse(repo); - if (!repoParse.success) { - failed = true; - reposValid = false; - handleZodError(repoParse.error, `Live REST - Repository: ${repo.name}`); - } - } - if (reposValid) { - console.log(` ${colors.green}✓ Live REST repositories schema matches expectations (checked ${liveRepos.length} repos).${colors.reset}`); - } - } else { - failed = true; - console.error(` ${colors.red}❌ Expected array of repos, got: ${typeof liveRepos}${colors.reset}`); - } - } catch (err) { - if (!token && err.status === 403) { - console.log(` ${colors.yellow}⚠ Skipped live repos REST fetch due to rate limiting.${colors.reset}`); - } else { - failed = true; - console.error(` ${colors.red}❌ Error fetching live Repos REST: ${err.message}${colors.reset}`); - } - } + await validateLiveRESTCollection({ + url: `https://api.github.com/orgs/${testOrg}/repos?per_page=5`, + schema: RepositorySchema, + startMsg: `Fetching live repositories for ${colors.bold}${testOrg}${colors.reset}...`, + successMsg: `Live REST repositories schema matches expectations (checked {count} repos).`, + skipMsg: `Skipped live repos REST fetch due to rate limiting.`, + errorMsg: `Error fetching live Repos REST`, + isArray: true, + collectionTypeLabel: 'repos', + getZodContext: (repo) => `Live REST - Repository: ${repo.name}` + }); - try { - console.log(` Fetching live contributors for ${colors.bold}${testOrg}/${testRepo}${colors.reset}...`); - const liveContribs = await fetchREST(`https://api.github.com/repos/${testOrg}/${testRepo}/contributors?per_page=5`); - if (Array.isArray(liveContribs)) { - let contribsValid = true; - for (const c of liveContribs) { - const cParse = ContributorSchema.safeParse(c); - if (!cParse.success) { - failed = true; - contribsValid = false; - handleZodError(cParse.error, `Live REST - Contributor: ${c.login}`); - } - } - if (contribsValid) { - console.log(` ${colors.green}✓ Live REST contributors schema matches expectations (checked ${liveContribs.length} contribs).${colors.reset}`); - } - } else { - failed = true; - console.error(` ${colors.red}❌ Expected array of contributors, got: ${typeof liveContribs}${colors.reset}`); - } - } catch (err) { - if (!token && err.status === 403) { - console.log(` ${colors.yellow}⚠ Skipped live contributors REST fetch due to rate limiting.${colors.reset}`); - } else { - failed = true; - console.error(` ${colors.red}❌ Error fetching live Contributors REST: ${err.message}${colors.reset}`); - } - } + await validateLiveRESTCollection({ + url: `https://api.github.com/repos/${testOrg}/${testRepo}/contributors?per_page=5`, + schema: ContributorSchema, + startMsg: `Fetching live contributors for ${colors.bold}${testOrg}/${testRepo}${colors.reset}...`, + successMsg: `Live REST contributors schema matches expectations (checked {count} contribs).`, + skipMsg: `Skipped live contributors REST fetch due to rate limiting.`, + errorMsg: `Error fetching live Contributors REST`, + isArray: true, + collectionTypeLabel: 'contributors', + getZodContext: (c) => `Live REST - Contributor: ${c.login}` + }); - try { - console.log(` Fetching live issues for ${colors.bold}${testOrg}/${testRepo}${colors.reset}...`); - const liveIssues = await fetchREST(`https://api.github.com/repos/${testOrg}/${testRepo}/issues?per_page=5&state=all`); - if (Array.isArray(liveIssues)) { - let issuesValid = true; - for (const issue of liveIssues) { - const issueParse = IssueSchema.safeParse(issue); - if (!issueParse.success) { - failed = true; - issuesValid = false; - handleZodError(issueParse.error, `Live REST - Issue: #${issue.number}`); - } - } - if (issuesValid) { - console.log(` ${colors.green}✓ Live REST issues schema matches expectations (checked ${liveIssues.length} issues).${colors.reset}`); - } - } else { - failed = true; - console.error(` ${colors.red}❌ Expected array of issues, got: ${typeof liveIssues}${colors.reset}`); - } - } catch (err) { - if (!token && err.status === 403) { - console.log(` ${colors.yellow}⚠ Skipped live issues REST fetch due to rate limiting.${colors.reset}`); - } else { - failed = true; - console.error(` ${colors.red}❌ Error fetching live Issues REST: ${err.message}${colors.reset}`); - } - } + await validateLiveRESTCollection({ + url: `https://api.github.com/repos/${testOrg}/${testRepo}/issues?per_page=5&state=all`, + schema: IssueSchema, + startMsg: `Fetching live issues for ${colors.bold}${testOrg}/${testRepo}${colors.reset}...`, + successMsg: `Live REST issues schema matches expectations (checked {count} issues).`, + skipMsg: `Skipped live issues REST fetch due to rate limiting.`, + errorMsg: `Error fetching live Issues REST`, + isArray: true, + collectionTypeLabel: 'issues', + getZodContext: (issue) => `Live REST - Issue: #${issue.number}` + }); - try { - console.log(` Fetching live rate limit status...`); - const liveRate = await fetchREST(`https://api.github.com/rate_limit`); - const rateParse = RateLimitSchema.safeParse(liveRate.rate); - if (!rateParse.success) { - failed = true; - handleZodError(rateParse.error, `Live REST - RateLimit`); - } else { - console.log(` ${colors.green}✓ Live REST rate limit schema matches expectations.${colors.reset}`); - } - } catch (err) { - if (!token && err.status === 403) { - console.log(` ${colors.yellow}⚠ Skipped live rate limit REST fetch due to rate limiting.${colors.reset}`); - } else { - failed = true; - console.error(` ${colors.red}❌ Error fetching live RateLimit REST: ${err.message}${colors.reset}`); - } - } + await validateLiveRESTCollection({ + url: `https://api.github.com/rate_limit`, + schema: RateLimitSchema, + startMsg: `Fetching live rate limit status...`, + successMsg: `Live REST rate limit schema matches expectations.`, + skipMsg: `Skipped live rate limit REST fetch due to rate limiting.`, + errorMsg: `Error fetching live RateLimit REST`, + isArray: false, + transform: (liveRate) => liveRate.rate, + getZodContext: () => `Live REST - RateLimit` + }); // Live GraphQL API checks if (token) { From 3a10e23930cc39f8e29bbdb2070fd59748055016 Mon Sep 17 00:00:00 2001 From: kpj2006 <24ucs074@lnmiit.ac.in> Date: Tue, 7 Jul 2026 22:45:07 +0530 Subject: [PATCH 7/7] Top-Level GraphQL Error Verification & Schema Unwrapping --- scripts/validate-api.js | 115 ++++++++++++++++++++-------------------- 1 file changed, 57 insertions(+), 58 deletions(-) diff --git a/scripts/validate-api.js b/scripts/validate-api.js index 4954953..1462688 100644 --- a/scripts/validate-api.js +++ b/scripts/validate-api.js @@ -83,72 +83,66 @@ export const RateLimitSchema = z.object({ // ========================================== export const GraphQLOrgDetailsSchema = z.object({ - data: z.object({ - organization: z.object({ - login: z.string(), - name: z.string().nullable().optional(), - description: z.string().nullable().optional(), - avatarUrl: z.url(), - repositories: z.object({ - totalCount: z.number(), - }), - }).nullable(), - }), + organization: z.object({ + login: z.string(), + name: z.string().nullable().optional(), + description: z.string().nullable().optional(), + avatarUrl: z.string().url(), + repositories: z.object({ + totalCount: z.number(), + }), + }).nullable(), }); export const GraphQLOrgReposSchema = z.object({ - data: z.object({ - organization: z.object({ - repositories: z.object({ - pageInfo: z.object({ - hasNextPage: z.boolean(), - endCursor: z.string().nullable().optional(), + organization: z.object({ + repositories: z.object({ + pageInfo: z.object({ + hasNextPage: z.boolean(), + endCursor: z.string().nullable().optional(), + }), + nodes: z.array(z.object({ + name: z.string(), + pushedAt: z.string(), + stargazerCount: z.number(), + forkCount: z.number(), + watchers: z.object({ + totalCount: z.number(), }), - nodes: z.array(z.object({ + primaryLanguage: z.object({ name: z.string(), - pushedAt: z.string(), - stargazerCount: z.number(), - forkCount: z.number(), - watchers: z.object({ - totalCount: z.number(), - }), - primaryLanguage: z.object({ - name: z.string(), - }).nullable().optional(), - licenseInfo: z.object({ - key: z.string(), - name: z.string(), - }).nullable().optional(), - isArchived: z.boolean(), - isFork: z.boolean(), - })), - }), - }).nullable(), - }), + }).nullable().optional(), + licenseInfo: z.object({ + key: z.string(), + name: z.string(), + }).nullable().optional(), + isArchived: z.boolean(), + isFork: z.boolean(), + })), + }), + }).nullable(), }); export const GraphQLRepoIssuesSchema = z.object({ - data: z.object({ - repository: z.object({ - issues: z.object({ - pageInfo: z.object({ - hasNextPage: z.boolean(), - endCursor: z.string().nullable().optional(), - }), - nodes: z.array(z.object({ - title: z.string(), - number: z.number(), - createdAt: z.string(), - updatedAt: z.string(), - state: z.string(), - url: z.url(), - author: z.object({ - login: z.string(), - }).nullable().optional(), - })), + repository: z.object({ + issues: z.object({ + pageInfo: z.object({ + hasNextPage: z.boolean(), + endCursor: z.string().nullable().optional(), }), - }).nullable(), - }), + nodes: z.array(z.object({ + title: z.string(), + number: z.number(), + createdAt: z.string(), + updatedAt: z.string(), + state: z.string(), + url: z.string().url(), + author: z.object({ + login: z.string(), + }).nullable().optional(), + })), + }), + }).nullable(), }); // ========================================== @@ -237,7 +231,12 @@ async function fetchGraphQL(query, variables = {}) { if (!res.ok) { throw new Error(`GraphQL Fetch failed: ${res.status} ${res.statusText}`); } - return res.json(); + const json = await res.json(); + if (json.errors && json.errors.length > 0) { + const errorMsg = json.errors.map(e => e.message).join('; '); + throw new Error(`GraphQL Error: ${errorMsg}`); + } + return json.data; } function handleZodError(error, contextName) {