V1 Git integration

This commit is contained in:
2025-10-15 14:35:31 +02:00
parent 9041c7db94
commit 173c63d907
2 changed files with 103 additions and 76 deletions

View File

@@ -181,24 +181,48 @@ async function fetchGitHubEvents({ baseUrl = 'https://api.github.com', username,
}
async function fetchGiteaLike({ baseUrl, username, token }, days = 365) {
const headers = { 'Accept': 'application/json' };
if (token) headers['Authorization'] = `token ${token}`;
let authMode = token ? 'token' : null; // 'token' | 'bearer' | null
const counts = {};
for (let page = 1; page <= 5; page++) {
for (let page = 1; page <= 8; page++) {
const url = `${baseUrl.replace(/\/$/, '')}/api/v1/users/${encodeURIComponent(username)}/events?limit=50&page=${page}`;
const res = await fetch(url, { headers });
if (!res.ok) break;
const events = await res.json();
let res;
try {
const headers = { 'Accept': 'application/json' };
if (token) headers['Authorization'] = authMode === 'bearer' ? `Bearer ${token}` : `token ${token}`;
res = await fetch(url, { headers });
} catch (e) {
break; // likely CORS/network
}
if (!res.ok) {
// Retry once with alternate auth scheme if unauthorized/forbidden
if ((res.status === 401 || res.status === 403) && token && authMode === 'token') {
authMode = 'bearer';
page--; // retry same page with Bearer
continue;
}
break;
}
let events;
try {
events = await res.json();
} catch {
break;
}
if (!Array.isArray(events) || events.length === 0) break;
for (const ev of events) {
const created = ev?.created || ev?.created_at || ev?.timestamp;
if (!created) continue;
if (isOlderThan(created, days)) { page = 999; break; }
const action = ev?.op_type || ev?.action || ev?.type;
if (String(action).toLowerCase().includes('push')) {
const day = formatDate(new Date(created));
counts[day] = (counts[day] || 0) + 1;
}
const actionRaw = (ev?.op_type || ev?.action || ev?.type || '').toString().toLowerCase();
const isPushLike = actionRaw.includes('push') || actionRaw.includes('commit');
if (!isPushLike) continue;
const day = formatDate(new Date(created));
// Try to take number of commits if provided
let inc = 1;
if (typeof ev?.commits_count === 'number') inc = ev.commits_count;
else if (typeof ev?.payload?.num_commits === 'number') inc = ev.payload.num_commits;
else if (Array.isArray(ev?.payload?.commits)) inc = ev.payload.commits.length || 1;
counts[day] = (counts[day] || 0) + (inc || 1);
}
}
return counts;
@@ -237,7 +261,8 @@ export async function fetchAllGitActivity({ force = false, days = 365 } = {}) {
const perSource = [];
for (const src of integrations) {
const token = await decryptToken(src.tokenEnc);
const info = { baseUrl: src.baseUrl, username: src.username, token };
const baseUrl = src.baseUrl || (src.provider === 'gitea' || src.provider === 'forgejo' ? 'https://gitea.com' : undefined);
const info = { baseUrl, username: src.username, token };
try {
if (src.provider === 'github') {
perSource.push(await fetchGitHubEvents(info, days));

View File

@@ -149,71 +149,7 @@ const SettingsPage = () => {
</motion.div>
<div className="space-y-4">
{/* Integrations */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700"
>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2"><GitBranch className="w-4 h-4" /> Integrations</h2>
<div className="flex items-center justify-between mb-4">
<div>
<Label htmlFor="git-enabled" className="text-base">Show Git Activity</Label>
<p className="text-sm text-muted-foreground">Display a unified Git activity grid</p>
</div>
<Switch id="git-enabled" checked={gitEnabled} onCheckedChange={toggleGitEnabled} />
</div>
<div className="grid sm:grid-cols-4 gap-2 mb-3">
<div>
<Label className="text-xs">Provider</Label>
<select className="w-full bg-transparent border rounded-md p-2" value={form.provider} onChange={e => setForm({ ...form, provider: e.target.value })}>
<option value="github">GitHub</option>
<option value="gitlab">GitLab</option>
<option value="gitea">Gitea</option>
<option value="forgejo">Forgejo</option>
<option value="custom">Custom</option>
</select>
</div>
<div>
<Label className="text-xs">Base URL</Label>
<input className="w-full bg-transparent border rounded-md p-2" placeholder="e.g. https://api.github.com" value={form.baseUrl} onChange={e => setForm({ ...form, baseUrl: e.target.value })} />
</div>
<div>
<Label className="text-xs">Username</Label>
<input className="w-full bg-transparent border rounded-md p-2" placeholder="your-username" value={form.username} onChange={e => setForm({ ...form, username: e.target.value })} />
</div>
<div>
<Label className="text-xs">Token</Label>
<input className="w-full bg-transparent border rounded-md p-2" placeholder="personal access token" value={form.token} onChange={e => setForm({ ...form, token: e.target.value })} />
</div>
</div>
<Button onClick={handleAddSource} className="mb-4 rounded-full"><Plus className="w-4 h-4 mr-1" /> Add Source</Button>
<div className="flex items-center justify-between mt-2">
<Button variant="outline" onClick={handleSyncGit} disabled={syncing} className="rounded-full">
{syncing ? 'Syncing…' : 'Sync Git Data'}
</Button>
<span className="text-xs text-muted-foreground">{lastSync ? `Last sync: ${new Date(lastSync).toLocaleString()}` : ''}</span>
</div>
{sources.length > 0 && (
<div className="space-y-2">
{sources.map(src => (
<div key={src.id} className="flex items-center justify-between bg-slate-50/50 dark:bg-slate-900/30 border border-slate-200 dark:border-slate-700 rounded-md p-2">
<div className="text-sm">
<div className="font-medium">{src.provider} {src.username}</div>
<div className="text-xs text-muted-foreground">{src.baseUrl}</div>
</div>
<Button size="icon" variant="ghost" onClick={() => handleRemoveSource(src.id)} className="rounded-full" aria-label="Remove Source">
<Trash className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
</motion.div>
{/* Appearance */}
<motion.div
initial={{ opacity: 0, y: 20 }}
@@ -299,6 +235,72 @@ const SettingsPage = () => {
</Button>
</motion.div>
{/* Integrations */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700"
>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2"><GitBranch className="w-4 h-4" /> Integrations</h2>
<div className="flex items-center justify-between mb-4">
<div>
<Label htmlFor="git-enabled" className="text-base">Show Git Activity</Label>
<p className="text-sm text-muted-foreground">Display a unified Git activity grid</p>
</div>
<Switch id="git-enabled" checked={gitEnabled} onCheckedChange={toggleGitEnabled} />
</div>
<div className="grid sm:grid-cols-4 gap-2 mb-3">
<div>
<Label className="text-xs">Provider</Label>
<select className="w-full bg-transparent border rounded-md p-2" value={form.provider} onChange={e => setForm({ ...form, provider: e.target.value })}>
<option value="github">GitHub</option>
<option value="gitlab">GitLab</option>
<option value="gitea">Gitea</option>
<option value="forgejo">Forgejo</option>
<option value="custom">Custom</option>
</select>
</div>
<div>
<Label className="text-xs">Base URL</Label>
<input className="w-full bg-transparent border rounded-md p-2" placeholder="GitHub: https://api.github.com • GitLab: https://gitlab.com • Gitea/Forgejo: https://your.instance" value={form.baseUrl} onChange={e => setForm({ ...form, baseUrl: e.target.value })} />
</div>
<div>
<Label className="text-xs">Username</Label>
<input className="w-full bg-transparent border rounded-md p-2" placeholder="your-username" value={form.username} onChange={e => setForm({ ...form, username: e.target.value })} />
</div>
<div>
<Label className="text-xs">Token</Label>
<input className="w-full bg-transparent border rounded-md p-2" placeholder="personal access token" value={form.token} onChange={e => setForm({ ...form, token: e.target.value })} />
</div>
</div>
<Button onClick={handleAddSource} className="mb-4 rounded-full"><Plus className="w-4 h-4 mr-1" /> Add Source</Button>
<div className="flex items-center justify-between mt-2">
<Button variant="outline" onClick={handleSyncGit} disabled={syncing} className="rounded-full">
{syncing ? 'Syncing…' : 'Sync Git Data'}
</Button>
<span className="text-xs text-muted-foreground">{lastSync ? `Last sync: ${new Date(lastSync).toLocaleString()}` : ''}</span>
</div>
{sources.length > 0 && (
<div className="space-y-2">
{sources.map(src => (
<div key={src.id} className="flex items-center justify-between bg-slate-50/50 dark:bg-slate-900/30 border border-slate-200 dark:border-slate-700 rounded-md p-2">
<div className="text-sm">
<div className="font-medium">{src.provider} {src.username}</div>
<div className="text-xs text-muted-foreground">{src.baseUrl}</div>
</div>
<Button size="icon" variant="ghost" onClick={() => handleRemoveSource(src.id)} className="rounded-full" aria-label="Remove Source">
<Trash className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
</motion.div>
{/* About */}
<motion.div
initial={{ opacity: 0, y: 20 }}