Skip to content
Draft
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
27 changes: 22 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions services/cacheService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export const CacheKeys = {
prDetails: (owner: string, repo: string, prNumber: number) => `pr_${owner}_${repo}_${prNumber}`,
issueExpandedData: (owner: string, repo: string, issueNumber: number) => `expanded_${owner}_${repo}_${issueNumber}`,
workflowFiles: () => 'workflow_files',
repoPullRequests: (owner: string, repo: string) => `pulls_${owner}_${repo}`,
repoDeployments: (owner: string, repo: string) => `deployments_${owner}_${repo}`,
};

// Type for cached expanded issue data (all data needed for expanded view)
Expand Down
32 changes: 32 additions & 0 deletions services/githubService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,38 @@ export const validateToken = async (token: string): Promise<GitHubUser> => {
return response.json();
};

export const fetchPullRequests = async (token: string, owner: string, repo: string): Promise<Issue[]> => {
const response = await fetch(`${GITHUB_API_BASE}/repos/${owner}/${repo}/pulls?state=all&per_page=30&sort=updated&direction=desc`, {
headers: {
Authorization: `token ${token}`,
Accept: 'application/vnd.github.v3+json',
},
cache: 'no-cache',
});

if (!response.ok) {
throw new Error('Failed to fetch pull requests');
}

return response.json();
};

export const fetchDeployments = async (token: string, owner: string, repo: string): Promise<Deployment[]> => {
const response = await fetch(`${GITHUB_API_BASE}/repos/${owner}/${repo}/deployments?per_page=30`, {
headers: {
Authorization: `token ${token}`,
Accept: 'application/vnd.github.v3+json',
},
cache: 'no-cache',
});

if (!response.ok) {
throw new Error('Failed to fetch deployments');
}

return response.json();
};

export const fetchRepositories = async (token: string, page = 1): Promise<Repository[]> => {
const response = await fetch(`${GITHUB_API_BASE}/user/repos?sort=updated&per_page=12&page=${page}`, {
headers: {
Expand Down
42 changes: 28 additions & 14 deletions views/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useEffect, useState, useRef } from 'react';
import { Repository, GitHubUser, RepoDraft, Issue, GitHubTemplate } from '../types';
import { fetchRepositories, createRepository, deleteRepository, fetchGitHubTemplates } from '../services/githubService';
import { fetchRepositories, createRepository, deleteRepository, fetchGitHubTemplates, fetchIssues, fetchPullRequests, fetchWorkflowRuns, fetchDeployments } from '../services/githubService';
import { completeRepositorySetup } from '../services/repoSetupUtils';
import { RepoCard } from '../components/RepoCard';
import { Button } from '../components/Button';
Expand Down Expand Up @@ -142,23 +142,37 @@ export const Dashboard: React.FC<DashboardProps> = ({ token, user, onRepoSelect,
// Cache the repos for instant display on next visit
setCache(CacheKeys.repos(), data);

// Load issues for first 4 repos - reuse cache when available
const reposToShow = data.slice(0, 4);
// Pre-fetch issues, PRs, actions, and deployments for the top 4 repos
const reposToPreload = data.slice(0, 4);
const issuesMap: Record<number, Issue[]> = {};

for (const repo of reposToShow) {
const cacheKey = CacheKeys.repoIssues(repo.owner.login, repo.name);
const cachedIssues = getCached<Issue[]>(cacheKey);

if (cachedIssues) {
// Reuse cached issues - filter to actual issues (not PRs) and take first 3
const actualIssues = cachedIssues.filter(issue => !issue.pull_request).slice(0, 3);

await Promise.allSettled(reposToPreload.map(async (repo) => {
try {
// Fetch all data in parallel
const [issues, pullRequests, workflowRuns, deployments] = await Promise.all([
fetchIssues(token, repo.owner.login, repo.name),
fetchPullRequests(token, repo.owner.login, repo.name),
fetchWorkflowRuns(token, repo.owner.login, repo.name),
fetchDeployments(token, repo.owner.login, repo.name),
]);

// Cache each dataset
setCache(CacheKeys.repoIssues(repo.owner.login, repo.name), issues);
setCache(CacheKeys.repoPullRequests(repo.owner.login, repo.name), pullRequests);
setCache(CacheKeys.workflowRuns(repo.owner.login, repo.name), workflowRuns);
setCache(CacheKeys.repoDeployments(repo.owner.login, repo.name), deployments);

// Update the local state for immediate UI update (for issues)
const actualIssues = issues.filter(issue => !issue.pull_request).slice(0, 3);
issuesMap[repo.id] = actualIssues;

} catch (error) {
// Log errors but don't block the UI
console.error(`Failed to preload data for ${repo.name}:`, error);
}
// If not cached, leave empty - issues will be cached when user visits repo detail
}
}));

setRepoIssues(issuesMap);
setRepoIssues(prev => ({ ...prev, ...issuesMap }));
} catch (err) {
// Only show error if we don't have cached data to display
if (!hasCachedData) {
Expand Down
56 changes: 37 additions & 19 deletions views/RepoDetail.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useEffect, useState, useRef } from 'react';
import { Repository, Issue, WorkflowFile, RepoSecret } from '../types';
import { fetchIssues, createIssue, fetchAllWorkflowFiles, fetchRepositorySecrets, setRepositorySecret, deleteRepositorySecret, createIssueComment } from '../services/githubService';
import { Repository, Issue, WorkflowFile, RepoSecret, WorkflowRun, Deployment } from '../types';
import { fetchIssues, createIssue, fetchAllWorkflowFiles, fetchRepositorySecrets, setRepositorySecret, deleteRepositorySecret, createIssueComment, fetchPullRequests, fetchWorkflowRuns, fetchDeployments } from '../services/githubService';
import { autoSetAllTokens, setupRepositoryWorkflows } from '../services/repoSetupUtils';
import { Button } from '../components/Button';
import { ToastContainer, useToast } from '../components/Toast';
Expand All @@ -16,15 +16,17 @@ interface RepoDetailProps {

export const RepoDetail: React.FC<RepoDetailProps> = ({ token, repo, onBack, onIssueSelect }) => {
const { toasts, dismissToast, showError } = useToast();
const cacheKey = CacheKeys.repoIssues(repo.owner.login, repo.name);

// Initialize from cache for instant display
const [issues, setIssues] = useState<Issue[]>(() => {
return getCached<Issue[]>(cacheKey) || [];
});
// Initialize all data from cache for instant display
const [issues, setIssues] = useState<Issue[]>(() => getCached<Issue[]>(CacheKeys.repoIssues(repo.owner.login, repo.name)) || []);
const [pullRequests, setPullRequests] = useState<Issue[]>(() => getCached<Issue[]>(CacheKeys.repoPullRequests(repo.owner.login, repo.name)) || []);
const [workflowRuns, setWorkflowRuns] = useState<WorkflowRun[]>(() => getCached<WorkflowRun[]>(CacheKeys.workflowRuns(repo.owner.login, repo.name)) || []);
const [deployments, setDeployments] = useState<Deployment[]>(() => getCached<Deployment[]>(CacheKeys.repoDeployments(repo.owner.login, repo.name)) || []);

const [loading, setLoading] = useState(() => {
// Only show loading if no cached data
return !getCached<Issue[]>(cacheKey);
// Show loading if any of the primary data is not cached
return !getCached(CacheKeys.repoIssues(repo.owner.login, repo.name)) ||
!getCached(CacheKeys.repoPullRequests(repo.owner.login, repo.name));
});
const [isRefreshing, setIsRefreshing] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
Expand Down Expand Up @@ -81,32 +83,48 @@ export const RepoDetail: React.FC<RepoDetailProps> = ({ token, repo, onBack, onI
// Filter out pull requests from the main list
const issuesOnly = issues.filter(issue => !issue.pull_request);

const loadIssues = React.useCallback(async (isManualRefresh = false) => {
const hasCachedData = issues.length > 0;
const loadRepoData = React.useCallback(async (isManualRefresh = false) => {
const hasCachedData = issues.length > 0 || pullRequests.length > 0;

// Show full loading only on first load with no cache
if (!hasCachedData) {
setLoading(true);
} else if (isManualRefresh) {
setIsRefreshing(true);
}

try {
const data = await fetchIssues(token, repo.owner.login, repo.name);
setIssues(data);
// Cache the issues for instant display on next visit
setCache(cacheKey, data);
// Fetch all data in parallel
const [fetchedIssues, fetchedPrs, fetchedRuns, fetchedDeployments] = await Promise.all([
fetchIssues(token, repo.owner.login, repo.name),
fetchPullRequests(token, repo.owner.login, repo.name),
fetchWorkflowRuns(token, repo.owner.login, repo.name),
fetchDeployments(token, repo.owner.login, repo.name),
]);

// Update state
setIssues(fetchedIssues);
setPullRequests(fetchedPrs);
setWorkflowRuns(fetchedRuns);
setDeployments(fetchedDeployments);

// Cache all the data for next time
setCache(CacheKeys.repoIssues(repo.owner.login, repo.name), fetchedIssues);
setCache(CacheKeys.repoPullRequests(repo.owner.login, repo.name), fetchedPrs);
setCache(CacheKeys.workflowRuns(repo.owner.login, repo.name), fetchedRuns);
setCache(CacheKeys.repoDeployments(repo.owner.login, repo.name), fetchedDeployments);

} catch (err) {
console.error(err);
showError("Failed to load repository data.");
} finally {
setLoading(false);
setIsRefreshing(false);
}
}, [token, repo, cacheKey, issues.length]);
}, [token, repo, issues.length, pullRequests.length, showError]);

useEffect(() => {
// Always fetch fresh data on mount, but show cached immediately
loadIssues(false);
loadRepoData(false);
}, []); // eslint-disable-line react-hooks/exhaustive-deps

const handleCreateIssue = async (e: React.FormEvent) => {
Expand Down Expand Up @@ -301,7 +319,7 @@ export const RepoDetail: React.FC<RepoDetailProps> = ({ token, repo, onBack, onI
{isRefreshing && (
<span className="text-sm text-slate-500 dark:text-slate-400 animate-pulse">Updating...</span>
)}
<Button variant="secondary" onClick={() => loadIssues(true)} icon={<RefreshCw size={16} className={isRefreshing ? 'animate-spin' : ''} />} disabled={isRefreshing}>
<Button variant="secondary" onClick={() => loadRepoData(true)} icon={<RefreshCw size={16} className={isRefreshing ? 'animate-spin' : ''} />} disabled={isRefreshing}>
Refresh
</Button>
<Button variant="secondary" onClick={() => setIsSecretsModalOpen(true)} icon={<Key size={16} />}>
Expand Down