Skip to content

Commit 0c89b71

Browse files
committed
feat: Integrate GitHub release fetching
Adds functionality to fetch the latest GitHub releases for configured repositories. - Introduces `ReleaseInfo` type for release data. - Stores latest release information in the `latestReleases` state in `App.tsx`. - Fetches release data when GitHub PAT is available and repositories are loaded. - Passes `latestRelease` prop to `RepositoryCard` for display. - Updates `DashboardProps` and `RepositoryCardProps` to include `latestReleases` and `latestRelease` respectively. - Adds `githubPat` field to `GlobalSettings` type. - Imports `ReleaseInfo` type into relevant components and files.
1 parent ec99bf0 commit 0c89b71

File tree

9 files changed

+215
-15
lines changed

9 files changed

+215
-15
lines changed

App.tsx

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import React, { useState, useCallback, useEffect, useMemo } from 'react';
22
import { useRepositoryManager } from './hooks/useRepositoryManager';
3-
import type { Repository, GlobalSettings, AppView, Task, LogEntry, LocalPathState, Launchable, LaunchConfig, DetailedStatus, BranchInfo, UpdateStatusMessage, ToastMessage, Category } from './types';
3+
// FIX START: Import ReleaseInfo type.
4+
import type { Repository, GlobalSettings, AppView, Task, LogEntry, LocalPathState, Launchable, LaunchConfig, DetailedStatus, BranchInfo, UpdateStatusMessage, ToastMessage, Category, ReleaseInfo } from './types';
5+
// FIX END
46
import Dashboard from './components/Dashboard';
57
import Header from './components/Header';
68
import RepoEditView from './components/modals/RepoFormModal';
@@ -74,6 +76,9 @@ const App: React.FC = () => {
7476
// New states for deeper VCS integration
7577
const [detailedStatuses, setDetailedStatuses] = useState<Record<string, DetailedStatus | null>>({});
7678
const [branchLists, setBranchLists] = useState<Record<string, BranchInfo | null>>({});
79+
// FIX START: Add state for latestReleases to satisfy DashboardProps.
80+
const [latestReleases, setLatestReleases] = useState<Record<string, ReleaseInfo | null>>({});
81+
// FIX END
7782

7883
const [taskLogState, setTaskLogState] = useState({
7984
isOpen: false,
@@ -260,6 +265,39 @@ const App: React.FC = () => {
260265
fetchStatuses();
261266
}, [repositories, localPathStates, isDataLoading, logger]);
262267

268+
// FIX START: Add effect to fetch latest releases for repositories.
269+
useEffect(() => {
270+
if (isDataLoading || !settings.githubPat) {
271+
if (!settings.githubPat) {
272+
logger.warn('GitHub PAT not set. Skipping release fetch.');
273+
}
274+
setLatestReleases({}); // Clear if no PAT or still loading
275+
return;
276+
}
277+
278+
const fetchReleases = async () => {
279+
logger.debug('Fetching latest GitHub releases for valid Git repositories.');
280+
const releasePromises = repositories
281+
.filter(repo => repo.vcs === VcsType.Git && localPathStates[repo.id] === 'valid')
282+
.map(async (repo) => {
283+
try {
284+
const releaseInfo = await window.electronAPI?.getLatestRelease(repo);
285+
return { repoId: repo.id, releaseInfo };
286+
} catch (error: any) {
287+
logger.error(`Failed to fetch release for ${repo.name}:`, { error: error.message });
288+
return { repoId: repo.id, releaseInfo: null }; // Ensure we return a value even on error
289+
}
290+
});
291+
292+
const releases = await Promise.all(releasePromises);
293+
setLatestReleases(releases.reduce((acc, r) => ({ ...acc, [r.repoId]: r.releaseInfo }), {}));
294+
logger.info('GitHub release fetch complete.');
295+
};
296+
297+
fetchReleases();
298+
}, [repositories, localPathStates, isDataLoading, logger, settings.githubPat]);
299+
// FIX END
300+
263301

264302
// Effect to detect executables when paths are validated
265303
useEffect(() => {
@@ -327,8 +365,13 @@ const App: React.FC = () => {
327365
logger.info('Branch changed on disk, updating repository state.', { repoId, old: repo.branch, new: branches.current });
328366
updateRepository({ ...repo, branch: branches.current });
329367
}
368+
// Also refresh release info
369+
if (settings.githubPat) {
370+
const releaseInfo = await window.electronAPI?.getLatestRelease(repo);
371+
setLatestReleases(prev => ({ ...prev, [repoId]: releaseInfo }));
372+
}
330373
}
331-
}, [repositories, localPathStates, updateRepository, logger]);
374+
}, [repositories, localPathStates, updateRepository, logger, settings.githubPat]);
332375

333376
const handleOpenContextMenu = useCallback((event: React.MouseEvent, repo: Repository) => {
334377
event.preventDefault();
@@ -873,6 +916,9 @@ const App: React.FC = () => {
873916
detectedExecutables={detectedExecutables}
874917
detailedStatuses={detailedStatuses}
875918
branchLists={branchLists}
919+
// FIX START: Pass latestReleases prop to Dashboard.
920+
latestReleases={latestReleases}
921+
// FIX END
876922
onSwitchBranch={handleSwitchBranch}
877923
onCloneRepo={(repoId) => {
878924
const repo = repositories.find(r => r.id === repoId);
@@ -1027,4 +1073,4 @@ const App: React.FC = () => {
10271073
);
10281074
};
10291075

1030-
export default App;
1076+
export default App;

components/Dashboard.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useState, useCallback, useMemo } from 'react';
2-
import type { Repository, Category, LocalPathState, DetailedStatus, BranchInfo, ToastMessage } from '../types';
2+
import type { Repository, Category, LocalPathState, DetailedStatus, BranchInfo, ToastMessage, ReleaseInfo } from '../types';
33
import RepositoryCard from './RepositoryCard';
44
import CategoryHeader from './CategoryHeader';
55
import { PlusIcon } from './icons/PlusIcon';
@@ -27,6 +27,7 @@ interface DashboardProps {
2727
detectedExecutables: Record<string, string[]>;
2828
detailedStatuses: Record<string, DetailedStatus | null>;
2929
branchLists: Record<string, BranchInfo | null>;
30+
latestReleases: Record<string, ReleaseInfo | null>;
3031
onSwitchBranch: (repoId: string, branch: string) => void;
3132
onCloneRepo: (repoId: string) => void;
3233
onChooseLocationAndClone: (repoId: string) => void;
@@ -46,7 +47,8 @@ const Dashboard: React.FC<DashboardProps> = (props) => {
4647
categories,
4748
uncategorizedOrder,
4849
onAddCategory,
49-
onMoveRepositoryToCategory
50+
onMoveRepositoryToCategory,
51+
latestReleases
5052
} = props;
5153

5254
const logger = useLogger();
@@ -155,9 +157,10 @@ const Dashboard: React.FC<DashboardProps> = (props) => {
155157
localPathState={props.localPathStates[repo.id] || 'checking'}
156158
detailedStatus={props.detailedStatuses[repo.id] || null}
157159
branchInfo={props.branchLists[repo.id] || null}
160+
latestRelease={latestReleases[repo.id] || null}
158161
detectedExecutables={props.detectedExecutables[repo.id] || []}
159-
onDragStart={(e) => handleDragStart(repo.id, categoryId)}
160-
onDragEnd={(e) => { setDraggedRepo(null); setDropIndicator(null); }}
162+
onDragStart={() => handleDragStart(repo.id, categoryId)}
163+
onDragEnd={() => { setDraggedRepo(null); setDropIndicator(null); }}
161164
isBeingDragged={isBeingDragged}
162165
dropIndicatorPosition={indicator ? indicator.position : null}
163166
onDragOver={(e) => handleDragOver(e, categoryId, repo.id)}

components/RepositoryCard.tsx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useState, useRef, useEffect, useContext } from 'react';
2-
import type { Repository, GitRepository, LocalPathState, DetailedStatus, BranchInfo, Task, LaunchConfig, WebLinkConfig, ToastMessage } from '../types';
2+
import type { Repository, GitRepository, LocalPathState, DetailedStatus, BranchInfo, Task, LaunchConfig, WebLinkConfig, ToastMessage, ReleaseInfo } from '../types';
33
import { VcsType } from '../types';
44
import { STATUS_COLORS, BUILD_HEALTH_COLORS } from '../constants';
55
import { PlayIcon } from './icons/PlayIcon';
@@ -37,6 +37,7 @@ interface RepositoryCardProps {
3737
localPathState: LocalPathState;
3838
detailedStatus: DetailedStatus | null;
3939
branchInfo: BranchInfo | null;
40+
latestRelease: ReleaseInfo | null;
4041
onSwitchBranch: (repoId: string, branch: string) => void;
4142
detectedExecutables: string[];
4243
onCloneRepo: (repoId: string) => void;
@@ -305,6 +306,7 @@ const RepositoryCard: React.FC<RepositoryCardProps> = ({
305306
localPathState,
306307
detailedStatus,
307308
branchInfo,
309+
latestRelease,
308310
onSwitchBranch,
309311
detectedExecutables,
310312
onCloneRepo,
@@ -351,13 +353,56 @@ const RepositoryCard: React.FC<RepositoryCardProps> = ({
351353
const configureTooltip = useTooltip('Configure Repository');
352354
const deleteTooltip = useTooltip('Delete Repository');
353355
const refreshTooltip = useTooltip('Refresh Status');
356+
const releaseUrlTooltip = useTooltip(latestRelease?.url);
354357

355358
const cardClasses = [
356359
'relative',
357360
'bg-white dark:bg-gray-800 rounded-lg shadow-lg flex flex-col',
358361
'transition-all duration-300 hover:shadow-blue-500/20',
359362
isBeingDragged ? 'opacity-40 scale-95' : 'opacity-100 scale-100',
360363
].join(' ');
364+
365+
const renderReleaseInfo = () => {
366+
if (vcs !== VcsType.Git) return null;
367+
let content;
368+
if (latestRelease) {
369+
const { tagName, isDraft, isPrerelease } = latestRelease;
370+
let badge;
371+
if (isDraft) {
372+
badge = <span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-gray-200">Draft</span>;
373+
} else if (isPrerelease) {
374+
badge = <span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-200 dark:bg-yellow-700 text-yellow-800 dark:text-yellow-200">Pre-release</span>;
375+
} else {
376+
badge = <span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-green-200 dark:bg-green-700 text-green-800 dark:text-green-200">Published</span>;
377+
}
378+
content = (
379+
<div className="flex items-center gap-2">
380+
<a
381+
{...releaseUrlTooltip}
382+
href={latestRelease.url}
383+
target="_blank"
384+
rel="noopener noreferrer"
385+
onClick={(e) => { e.preventDefault(); onOpenWeblink(latestRelease.url); }}
386+
className="font-semibold text-blue-600 dark:text-blue-400 hover:underline"
387+
>
388+
{tagName}
389+
</a>
390+
{badge}
391+
</div>
392+
);
393+
} else if (latestRelease === null) {
394+
content = <span className="font-semibold text-gray-400">No releases found.</span>;
395+
} else {
396+
content = <span className="font-semibold text-gray-400 italic">Checking...</span>;
397+
}
398+
return (
399+
<div className="mt-1 flex items-center justify-between text-sm">
400+
<span className="text-gray-400 dark:text-gray-500">Latest Release:</span>
401+
{content}
402+
</div>
403+
);
404+
};
405+
361406

362407
return (
363408
<div
@@ -486,6 +531,7 @@ const RepositoryCard: React.FC<RepositoryCardProps> = ({
486531
{lastUpdated ? new Date(lastUpdated).toLocaleString() : 'Never'}
487532
</span>
488533
</div>
534+
{renderReleaseInfo()}
489535
</div>
490536

491537
<div className="border-t border-gray-200 dark:border-gray-700 p-2 bg-gray-50 dark:bg-gray-800/50">

components/SettingsView.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
2-
31
import React, { useState, useEffect } from 'react';
42
import type { GlobalSettings } from '../types';
53
import { SunIcon } from './icons/SunIcon';
@@ -183,6 +181,19 @@ const SettingsView: React.FC<SettingsViewProps> = ({ onSave, currentSettings, se
183181
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Behavior</h2>
184182
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Configure how the application functions.</p>
185183
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 space-y-6">
184+
<div>
185+
<label htmlFor="githubPat" className="block text-sm font-medium text-gray-700 dark:text-gray-300">GitHub Personal Access Token</label>
186+
<input
187+
type="password"
188+
id="githubPat"
189+
name="githubPat"
190+
value={settings.githubPat || ''}
191+
onChange={handleChange}
192+
className="mt-1 block w-full max-w-md bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm py-1.5 px-3 text-gray-900 dark:text-white focus:outline-none focus:ring-blue-500 focus:border-blue-500"
193+
/>
194+
<p className="mt-1 text-xs text-gray-500">Required for fetching release info. <a href="https://github.com/settings/tokens?type=beta" target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">Create a fine-grained token</a> with `Read-only` access to `Contents`.</p>
195+
</div>
196+
186197
<div>
187198
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Open Web Links In</label>
188199
<div className="mt-2 flex rounded-md bg-gray-200 dark:bg-gray-900 p-1 max-w-md">

contexts/SettingsContext.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const DEFAULTS: GlobalSettings = {
3333
debugLogging: true,
3434
allowPrerelease: true,
3535
openLinksIn: 'default',
36+
githubPat: '',
3637
};
3738

3839
const initialState: AppDataContextState = {
@@ -306,7 +307,7 @@ export const SettingsProvider: React.FC<{ children: ReactNode }> = ({ children }
306307
moveRepositoryToCategory,
307308
toggleCategoryCollapse,
308309
toggleAllCategoriesCollapse,
309-
}), [settings, saveSettings, repositories, isLoading, categories, uncategorizedOrder, addCategory, updateCategory, deleteCategory, moveRepositoryToCategory, toggleCategoryCollapse, toggleAllCategoriesCollapse]);
310+
}), [settings, saveSettings, repositories, isLoading, categories, uncategorizedOrder, addCategory, updateCategory, deleteCategory, moveRepositoryToCategory, toggleCategoryCollapse, toggleAllCategoriesCollapse, addRepository, updateRepository, deleteRepository, setRepositories, setCategories, setUncategorizedOrder]);
310311

311312
return (
312313
<SettingsContext.Provider value={value}>

electron/electron.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { IpcRendererEvent } from 'electron';
2-
import type { Repository, Task, TaskStep, GlobalSettings, LogLevel, LocalPathState as AppLocalPathState, DetailedStatus, Commit, BranchInfo, DebugLogEntry, VcsType, ProjectInfo, UpdateStatusMessage, Category, AppDataContextState } from '../types';
2+
import type { Repository, Task, TaskStep, GlobalSettings, LogLevel, LocalPathState as AppLocalPathState, DetailedStatus, Commit, BranchInfo, DebugLogEntry, VcsType, ProjectInfo, UpdateStatusMessage, Category, AppDataContextState, ReleaseInfo } from '../types';
33

44
export type LocalPathState = AppLocalPathState;
55

@@ -26,6 +26,7 @@ export interface IElectronAPI {
2626
deleteBranch: (repoPath: string, branch: string, isRemote: boolean) => Promise<{ success: boolean; error?: string }>;
2727
mergeBranch: (repoPath: string, branch: string) => Promise<{ success: boolean; error?: string }>;
2828
ignoreFilesAndPush: (args: { repo: Repository; filesToIgnore: string[] }) => Promise<{ success: boolean; error?: string }>;
29+
getLatestRelease: (repo: Repository) => Promise<ReleaseInfo | null>;
2930

3031

3132
checkLocalPath: (path: string) => Promise<LocalPathState>;

electron/main.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import path, { dirname } from 'path';
44
import fs from 'fs/promises';
55
import os, { platform } from 'os';
66
import { spawn, exec, execFile } from 'child_process';
7-
import type { Repository, Task, TaskStep, TaskVariable, GlobalSettings, ProjectSuggestion, LocalPathState, DetailedStatus, VcsFileStatus, Commit, BranchInfo, DebugLogEntry, VcsType, PythonCapabilities, ProjectInfo, DelphiCapabilities, DelphiProject, NodejsCapabilities, LazarusCapabilities, LazarusProject, Category, AppDataContextState } from '../types';
7+
import type { Repository, Task, TaskStep, TaskVariable, GlobalSettings, ProjectSuggestion, LocalPathState, DetailedStatus, VcsFileStatus, Commit, BranchInfo, DebugLogEntry, VcsType, PythonCapabilities, ProjectInfo, DelphiCapabilities, DelphiProject, NodejsCapabilities, LazarusCapabilities, LazarusProject, Category, AppDataContextState, ReleaseInfo } from '../types';
88
import { TaskStepType, LogLevel, VcsType as VcsTypeEnum } from '../types';
99
import fsSync from 'fs';
1010
import JSZip from 'jszip';
@@ -78,6 +78,7 @@ const DEFAULTS: GlobalSettings = {
7878
debugLogging: true,
7979
allowPrerelease: true,
8080
openLinksIn: 'default',
81+
githubPat: '',
8182
};
8283

8384
async function readSettings(): Promise<GlobalSettings> {
@@ -220,6 +221,11 @@ ipcMain.handle('get-all-data', async (): Promise<AppDataContextState> => {
220221
}
221222
// --- End Migration ---
222223

224+
// Ensure githubPat exists
225+
if (parsedData.globalSettings && typeof parsedData.globalSettings.githubPat === 'undefined') {
226+
parsedData.globalSettings.githubPat = '';
227+
}
228+
223229
return parsedData;
224230
} catch (error: any) {
225231
// If file doesn't exist or is invalid, return empty structure
@@ -1717,4 +1723,75 @@ ipcMain.handle('import-settings', async (): Promise<{ success: boolean; error?:
17171723
console.error('Failed to import settings:', error);
17181724
return { success: false, error: error.message };
17191725
}
1726+
});
1727+
1728+
// --- GitHub Release Management ---
1729+
1730+
// Helper to parse owner/repo from various git URLs
1731+
const parseGitHubUrl = (url: string): { owner: string; repo: string } | null => {
1732+
if (!url) return null;
1733+
// HTTPS: https://github.com/owner/repo.git
1734+
let match = url.match(/github\.com[/:]([^/]+)\/([^/.]+)/);
1735+
if (match) {
1736+
return { owner: match[1], repo: match[2] };
1737+
}
1738+
// SSH: git@github.com:owner/repo.git
1739+
match = url.match(/git@github\.com:([^/]+)\/([^/.]+)/);
1740+
if (match) {
1741+
return { owner: match[1], repo: match[2] };
1742+
}
1743+
return null;
1744+
};
1745+
1746+
1747+
ipcMain.handle('get-latest-release', async (event, repo: Repository): Promise<ReleaseInfo | null> => {
1748+
const settings = await readSettings();
1749+
if (!settings.githubPat) {
1750+
// Don't throw, just return null so the UI can show a "token needed" message.
1751+
console.warn(`[GitHub] Cannot fetch releases for ${repo.name}: GitHub PAT not set.`);
1752+
return null;
1753+
}
1754+
1755+
const ownerRepo = parseGitHubUrl(repo.remoteUrl);
1756+
if (!ownerRepo) {
1757+
console.warn(`[GitHub] Could not parse owner/repo from URL: ${repo.remoteUrl}`);
1758+
return null;
1759+
}
1760+
1761+
const { owner, repo: repoName } = ownerRepo;
1762+
const apiUrl = `https://api.github.com/repos/${owner}/${repoName}/releases/latest`;
1763+
1764+
try {
1765+
const response = await fetch(apiUrl, {
1766+
headers: {
1767+
'Authorization': `token ${settings.githubPat}`,
1768+
'Accept': 'application/vnd.github.v3+json',
1769+
'X-GitHub-Api-Version': '2022-11-28'
1770+
}
1771+
});
1772+
1773+
if (response.status === 404) {
1774+
console.log(`[GitHub] No latest release found for ${owner}/${repoName}.`);
1775+
return null; // This is a valid state, not an error.
1776+
}
1777+
1778+
if (!response.ok) {
1779+
const errorBody = await response.json();
1780+
throw new Error(`GitHub API error (${response.status}): ${errorBody.message || 'Unknown error'}`);
1781+
}
1782+
1783+
const data = await response.json();
1784+
1785+
return {
1786+
tagName: data.tag_name,
1787+
name: data.name,
1788+
isDraft: data.draft,
1789+
isPrerelease: data.prerelease,
1790+
url: data.html_url,
1791+
};
1792+
} catch (error: any) {
1793+
console.error(`[GitHub] Failed to fetch latest release for ${owner}/${repoName}:`, error);
1794+
// It's better to return null and let the UI handle it than to crash.
1795+
return null;
1796+
}
17201797
});

0 commit comments

Comments
 (0)