diff --git a/.github/workflows/api-validation.yml b/.github/workflows/api-validation.yml new file mode 100644 index 0000000..8bcd5db --- /dev/null +++ b/.github/workflows/api-validation.yml @@ -0,0 +1,40 @@ +name: API & Schema Validation + +permissions: + contents: read + +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@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + 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/.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 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..1462688 --- /dev/null +++ b/scripts/validate-api.js @@ -0,0 +1,498 @@ +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.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.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.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.url().optional(), + pull_request: z.object({ + url: z.url().optional(), + html_url: z.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({ + 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({ + 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({ + 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) { + 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}`); + } + 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}`); + } + 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) { + 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})`); + }); +} + +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() { + 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 + 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}` + }); + + 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}` + }); + + 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}` + }); + + 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}` + }); + + 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) { + 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 + } + } + } + } + } +`;