Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/fetch-cache.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Fetch and Cache GitHub Data

on:
schedule:
# Run every day at midnight UTC
- cron: '0 0 * * *'
workflow_dispatch:
# Allow manual triggering

permissions:
contents: write

jobs:
fetch-cache:
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

- name: Run Fetch Script
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/fetch-orgs.js

- name: Commit and Push Cache Files
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add public/data/
git diff-index --quiet HEAD || (git commit -m "chore: update cached organization data" && git push)
13 changes: 13 additions & 0 deletions public/data/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"updatedAt": "2026-07-07T19:10:50.827Z",
"orgs": [
{
"login": "aossie-org",
"name": "AOSSIE-Org"
},
{
"login": "djedalliance",
"name": "DjedAlliance"
}
]
}
297,068 changes: 297,068 additions & 0 deletions public/data/orgs/aossie-org.json

Large diffs are not rendered by default.

57,829 changes: 57,829 additions & 0 deletions public/data/orgs/djedalliance.json

Large diffs are not rendered by default.

177 changes: 177 additions & 0 deletions scripts/fetch-orgs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import fs from 'fs';
import path from 'path';

const ORGS = ['AOSSIE-Org', 'DjedAlliance', 'StabilityNexus'];
const MAX_REPO_PAGES = 5; // Up to 500 repositories per organization
const TOP_REPOS_LIMIT = 10; // Contributors/issues fetched for top N repositories
const MAX_ISSUE_PAGES = 3; // Up to 300 issues per repository for trends/governance

const GITHUB_TOKEN = process.env.GITHUB_TOKEN || '';

const headers = {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'OrgExplorer-Cache-Builder'
};

if (GITHUB_TOKEN) {
headers.Authorization = `token ${GITHUB_TOKEN}`;
console.log('Using GITHUB_TOKEN for authenticated API requests.');
} else {
console.log('Running unauthenticated (subject to lower rate limits).');
}

// Helper to make fetch calls with retries / error checks
async function fetchWithRetry(url) {
const res = await fetch(url, { headers });
if (res.status === 403) {
const rateLimitReset = res.headers.get('x-ratelimit-reset');
const resetTime = rateLimitReset ? new Date(Number(rateLimitReset) * 1000).toLocaleString() : 'unknown';
throw new Error(`GitHub API rate limit exceeded. Reset at: ${resetTime}`);
}
if (!res.ok) {
throw new Error(`HTTP Error ${res.status}: ${res.statusText} at ${url}`);
}
return res.json();
}

// Re-implementation of getTopRepositories from analytics.js
function getTopRepositories(repos, limit = 10) {
const MS_PER_DAY = 1000 * 60 * 60 * 24;
return [...repos]
.map(repo => {
const pushedAtMs = Date.parse(repo.pushed_at);
const daysSinceLastPush = Number.isFinite(pushedAtMs) ? (Date.now() - pushedAtMs) / MS_PER_DAY : Infinity;
const activityBonus = 0.5 * Math.max(0, 365 - daysSinceLastPush);
const score =
(repo.stargazers_count ?? 0) +
(repo.forks_count ?? 0) * 2 +
(repo.watchers_count ?? 0) * 1.5 +
activityBonus;
return {
...repo,
score,
};
})
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}

// Fetch all repositories for an organization
async function fetchAllRepos(org) {
const all = [];
for (let page = 1; page <= MAX_REPO_PAGES; page++) {
const url = `https://api.github.com/orgs/${org}/repos?per_page=100&page=${page}&sort=updated`;
console.log(` Fetching repos page ${page}...`);
const data = await fetchWithRetry(url);
all.push(...data);
if (data.length < 100) break;
}
return all;
}

// Fetch contributors for a repository
async function fetchRepoContributors(org, repo) {
const all = [];
for (let page = 1; ; page++) {
const url = `https://api.github.com/repos/${org}/${repo}/contributors?per_page=100&page=${page}`;
const data = await fetchWithRetry(url);
all.push(...data);
if (data.length < 100) break;
}
return all;
}

// Fetch issues for a repository
async function fetchRepoIssues(org, repo) {
const all = [];
for (let page = 1; page <= MAX_ISSUE_PAGES; page++) {
const url = `https://api.github.com/repos/${org}/${repo}/issues?state=all&per_page=100&page=${page}`;
const data = await fetchWithRetry(url);
all.push(...data);
if (data.length < 100) break;
}
return all;
}

async function run() {
const dataDir = path.join(process.cwd(), 'public', 'data');
const orgsDir = path.join(dataDir, 'orgs');

// Ensure directories exist
fs.mkdirSync(orgsDir, { recursive: true });

console.log(`Starting fetch for organizations: ${ORGS.join(', ')}`);

const manifestOrgs = [];

for (const orgName of ORGS) {
try {
console.log(`\nProcessing organization: ${orgName}`);

console.log(` Fetching metadata...`);
const orgData = await fetchWithRetry(`https://api.github.com/orgs/${orgName}`);

const allRepos = await fetchAllRepos(orgName);
console.log(` Total repos fetched: ${allRepos.length}`);

const topRepos = getTopRepositories(allRepos, TOP_REPOS_LIMIT);
console.log(` Identified top ${topRepos.length} repos for contributors & issues.`);

const contributors = {};
const issues = {};

for (const repo of topRepos) {
console.log(` Fetching data for repo: ${repo.name}`);
try {
contributors[repo.name] = await fetchRepoContributors(orgName, repo.name);
console.log(` Contributors fetched: ${contributors[repo.name].length}`);
} catch (err) {
console.error(` Failed to fetch contributors for ${repo.name}: ${err.message}`);
contributors[repo.name] = [];
}

try {
issues[repo.name] = await fetchRepoIssues(orgName, repo.name);
console.log(` Issues fetched: ${issues[repo.name].length}`);
} catch (err) {
console.error(` Failed to fetch issues for ${repo.name}: ${err.message}`);
issues[repo.name] = [];
}
}

const combinedData = {
org: orgData,
repos: allRepos,
contributors,
issues
};

const orgFilePath = path.join(orgsDir, `${orgName.toLowerCase()}.json`);
fs.writeFileSync(orgFilePath, JSON.stringify(combinedData, null, 2));
console.log(` Saved cached JSON to ${orgFilePath}`);

manifestOrgs.push({
login: orgName.toLowerCase(),
name: orgName
});

} catch (err) {
console.error(`Error processing organization ${orgName}: ${err.message}`);
}
}

// Save manifest
const manifest = {
updatedAt: new Date().toISOString(),
orgs: manifestOrgs
};
const manifestPath = path.join(dataDir, 'manifest.json');
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
console.log(`\nSaved manifest to ${manifestPath}`);
console.log('Caching script finished successfully.');
}

run().catch(err => {
console.error('Fatal error in caching script:', err);
process.exit(1);
});
126 changes: 89 additions & 37 deletions src/context/AppContext.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,20 @@ export function AppProvider({ children }) {
const [loadMsg, setLoadMsg] = useState('')
const [govLoading, setGovLoading] = useState(false)
const [error, setError] = useState('')
const [totalRepo, setTotalRepo] = useState(0)
const [cachedOrgs, setCachedOrgs] = useState([])

useEffect(() => {
fetch('/data/manifest.json')
.then(res => res.json())
.then(data => {
if (data && Array.isArray(data.orgs)) {
setCachedOrgs(data.orgs.map(o => o.login.toLowerCase()))
}
})
.catch(() => {
// Fallback to empty if manifest does not exist
})
}, [])

useEffect(() => {
const handler = e => {
Expand Down Expand Up @@ -78,46 +91,82 @@ export function AppProvider({ children }) {
setLoading(true); setError(''); setModel(null); setOrgs([]); setIssuesData({})
try {
setLoadMsg('Fetching organization metadata...')
const orgRes = await Promise.allSettled(orgNames.map(n => fetchOrg(n, pat)))
const validOrgs = orgRes.filter(r => r.status === 'fulfilled').map(r => r.value)
if (!validOrgs.length) throw new Error('No valid organizations found. Check the names and try again.')

const fetchPromises = orgNames.map(async name => {
const lowerName = name.toLowerCase()
if (cachedOrgs.includes(lowerName)) {
const res = await fetch(`/data/orgs/${lowerName}.json`)
if (!res.ok) throw new Error(`Failed to load cached data for ${name}`)
const data = await res.json()
return { cached: true, name, data }
} else {
const org = await fetchOrg(name, pat)
const repos = await fetchRepos(org.login, org.public_repos, pat)
return { cached: false, name, org, repos }
}
})

const results = await Promise.all(fetchPromises)

// 1. Set orgs (tag cached ones so OverviewPage can show badge)
const validOrgs = results.map(r => r.cached ? { ...r.data.org, cached: true } : r.org)
setOrgs(validOrgs)

setLoadMsg('Fetching repositories...')
// 2. Set total repos and reposPerOrg
const reposPerOrg = {}
await Promise.allSettled(validOrgs.map(async org => {
reposPerOrg[org.login] = await fetchRepos(org.login, org.public_repos, pat)
}))
const totalReposPerOrg = {}
results.forEach(r => {
const orgLogin = r.cached ? r.data.org.login : r.org.login
const repos = r.cached ? r.data.repos : r.repos
reposPerOrg[orgLogin] = repos
totalReposPerOrg[orgLogin] = [...repos]
})

const total = Object.values(reposPerOrg).reduce(
const total = Object.values(totalReposPerOrg).reduce(
(sum, repos) => sum + repos.length,
0
);

setTotalRepo(total);
const totalReposPerOrg = Object.fromEntries(
Object.entries(reposPerOrg).map(([org, repos]) => [
org,
[...repos], // copy each array
])
);
)
setTotalRepo(total)

// 3. Fetch contributor data for top repositories
setLoadMsg('Fetching contributor data for top repositories...')
const contribsPerRepo = {}
for (const org of validOrgs) {

const top = pat ? (reposPerOrg[org.login] || []) : getTopRepositories(reposPerOrg[org.login] || [], 10);
reposPerOrg[org.login] = top; // Update to only include top repos
const cachedIssuesData = {}

for (const r of results) {
const orgLogin = r.cached ? r.data.org.login : r.org.login
if (r.cached) {
const cachedContribs = r.data.contributors || {}
Object.entries(cachedContribs).forEach(([repoKey, contribList]) => {
const repoName = repoKey.includes('/') ? repoKey.split('/')[1] : repoKey
contribsPerRepo[`${orgLogin}/${repoName}`] = contribList
})

const cachedIssues = r.data.issues || {}
Object.entries(cachedIssues).forEach(([repoKey, issueList]) => {
const repoName = repoKey.includes('/') ? repoKey.split('/')[1] : repoKey
cachedIssuesData[`${orgLogin}/${repoName}`] = issueList
})

const top = pat ? (reposPerOrg[orgLogin] || []) : getTopRepositories(reposPerOrg[orgLogin] || [], 10)
reposPerOrg[orgLogin] = top
} else {
const top = pat ? (reposPerOrg[orgLogin] || []) : getTopRepositories(reposPerOrg[orgLogin] || [], 10)
reposPerOrg[orgLogin] = top

await Promise.allSettled(top.map(async repo => {
contribsPerRepo[`${orgLogin}/${repo.name}`] = await fetchContributors(orgLogin, repo.name, pat)
}))
}
}

await Promise.allSettled(top.map(async repo => {
contribsPerRepo[`${org.login}/${repo.name}`] = await fetchContributors(org.login, repo.name, pat)
}))
if (Object.keys(cachedIssuesData).length > 0) {
setIssuesData(prev => ({ ...prev, ...cachedIssuesData }))
}

setLoadMsg('Building analytical data model...')
setModel(buildAnalyticalModel(validOrgs, reposPerOrg, contribsPerRepo, totalReposPerOrg))


// Save to recent searches
const prev = JSON.parse(localStorage.getItem('oe_recent') || '[]')
const entry = orgNames.join(', ')
Expand All @@ -131,25 +180,28 @@ export function AppProvider({ children }) {
} finally {
setLoading(false); setLoadMsg('')
}
}, [pat])
}, [pat, cachedOrgs])

// Governance audit — parallel batches of 5 (Section 3.2.5)
const runAudit = useCallback(async () => {
if (!model || govLoading) return
setGovLoading(true)
const map = {}
const repos = pat? model.totalRepos : model.totalRepos.slice(0, 15)

// Batches of 5 using Promise.allSettled
for (let i = 0; i < repos.length; i += 5) {
const batch = repos.slice(i, i + 5)
await Promise.allSettled(batch.map(async repo => {
map[`${repo.orgLogin}/${repo.name}`] = await fetchIssues(repo.orgLogin, repo.name, pat)
}))
const map = { ...issuesData }
const repos = pat ? model.totalRepos : model.totalRepos.slice(0, 15)

const reposToFetch = repos.filter(repo => !map[`${repo.orgLogin}/${repo.name}`])

if (reposToFetch.length > 0) {
for (let i = 0; i < reposToFetch.length; i += 5) {
const batch = reposToFetch.slice(i, i + 5)
await Promise.allSettled(batch.map(async repo => {
map[`${repo.orgLogin}/${repo.name}`] = await fetchIssues(repo.orgLogin, repo.name, pat)
}))
}
}
setIssuesData(map)
setGovLoading(false)
}, [model, pat, govLoading])
}, [model, pat, govLoading, issuesData])

const STALE_DAYS = 90

Expand Down
Loading
Loading