mirror of
https://github.com/nagaoo0/HabbitGrid.git
synced 2026-01-11 23:44:55 +00:00
290 lines
10 KiB
JavaScript
290 lines
10 KiB
JavaScript
// Git integrations library: manages sources, token encryption, fetching events, and caching
|
|
import { formatDate } from './utils-habit';
|
|
|
|
const GIT_INT_KEY = 'habitgrid_git_integrations';
|
|
const GIT_CACHE_KEY = 'habitgrid_git_cache';
|
|
const GIT_ENABLED_KEY = 'habitgrid_git_enabled';
|
|
const GIT_KEY_MATERIAL = 'habitgrid_git_k';
|
|
|
|
// --- Minimal AES-GCM encryption helpers using Web Crypto ---
|
|
async function getCryptoKey() {
|
|
try {
|
|
let raw = localStorage.getItem(GIT_KEY_MATERIAL);
|
|
if (!raw) {
|
|
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
raw = btoa(String.fromCharCode(...bytes));
|
|
localStorage.setItem(GIT_KEY_MATERIAL, raw);
|
|
}
|
|
const buf = Uint8Array.from(atob(raw), c => c.charCodeAt(0));
|
|
return await crypto.subtle.importKey('raw', buf, 'AES-GCM', false, ['encrypt', 'decrypt']);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function encryptToken(token) {
|
|
const key = await getCryptoKey();
|
|
if (!key) return token; // fallback
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
const enc = new TextEncoder().encode(token);
|
|
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, enc));
|
|
return `${btoa(String.fromCharCode(...iv))}:${btoa(String.fromCharCode(...ct))}`;
|
|
}
|
|
|
|
export async function decryptToken(tokenEnc) {
|
|
const key = await getCryptoKey();
|
|
if (!key || !tokenEnc.includes(':')) return tokenEnc || '';
|
|
const [ivB64, ctB64] = tokenEnc.split(':');
|
|
const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0));
|
|
const ct = Uint8Array.from(atob(ctB64), c => c.charCodeAt(0));
|
|
try {
|
|
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
|
|
return new TextDecoder().decode(pt);
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
// --- Integrations CRUD ---
|
|
export function getGitEnabled() {
|
|
return localStorage.getItem(GIT_ENABLED_KEY) === 'true';
|
|
}
|
|
export function setGitEnabled(enabled) {
|
|
localStorage.setItem(GIT_ENABLED_KEY, enabled ? 'true' : 'false');
|
|
}
|
|
|
|
export function getIntegrations() {
|
|
try {
|
|
const raw = localStorage.getItem(GIT_INT_KEY);
|
|
return raw ? JSON.parse(raw) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function addIntegration({ provider, baseUrl, username, token }) {
|
|
const integrations = getIntegrations();
|
|
const tokenEnc = await encryptToken(token);
|
|
const id = Date.now().toString();
|
|
integrations.push({ id, provider, baseUrl, username, tokenEnc, createdAt: new Date().toISOString() });
|
|
localStorage.setItem(GIT_INT_KEY, JSON.stringify(integrations));
|
|
return id;
|
|
}
|
|
|
|
export function removeIntegration(id) {
|
|
const integrations = getIntegrations().filter(x => x.id !== id);
|
|
localStorage.setItem(GIT_INT_KEY, JSON.stringify(integrations));
|
|
}
|
|
|
|
// --- Caching ---
|
|
function getCache() {
|
|
try {
|
|
const raw = localStorage.getItem(GIT_CACHE_KEY);
|
|
return raw ? JSON.parse(raw) : { lastSync: null, dailyCounts: {} };
|
|
} catch {
|
|
return { lastSync: null, dailyCounts: {} };
|
|
}
|
|
}
|
|
function setCache(cache) {
|
|
localStorage.setItem(GIT_CACHE_KEY, JSON.stringify(cache));
|
|
}
|
|
|
|
export function getCachedGitActivity() {
|
|
return getCache();
|
|
}
|
|
|
|
// --- Fetch events per provider ---
|
|
function isOlderThan(dateStr, days) {
|
|
const d = new Date(dateStr);
|
|
const cutoff = new Date();
|
|
cutoff.setDate(cutoff.getDate() - days);
|
|
return d < cutoff;
|
|
}
|
|
|
|
function deriveGitHubGraphQLEndpoint(baseUrl) {
|
|
try {
|
|
const u = new URL(baseUrl || 'https://api.github.com');
|
|
// If /api/v3 -> likely /api/graphql
|
|
if (u.pathname.includes('/api/v3')) {
|
|
return `${u.origin}/api/graphql`;
|
|
}
|
|
// If ends with /api -> /graphql under same base
|
|
if (u.pathname.endsWith('/api')) {
|
|
return `${u.origin}/graphql`;
|
|
}
|
|
// Default: append /graphql
|
|
return `${baseUrl.replace(/\/$/, '')}/graphql`;
|
|
} catch {
|
|
return 'https://api.github.com/graphql';
|
|
}
|
|
}
|
|
|
|
async function fetchGitHubGraphQL({ baseUrl = 'https://api.github.com', username, token }, days = 365) {
|
|
if (!token) return null; // require token for GraphQL
|
|
const endpoint = deriveGitHubGraphQLEndpoint(baseUrl);
|
|
const to = new Date();
|
|
const from = new Date();
|
|
from.setDate(from.getDate() - days + 1);
|
|
const query = `query($login:String!, $from:DateTime!, $to:DateTime!) {
|
|
user(login:$login) {
|
|
contributionsCollection(from:$from, to:$to) {
|
|
contributionCalendar { weeks { contributionDays { date contributionCount } } }
|
|
}
|
|
}
|
|
}`;
|
|
const res = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({ query, variables: { login: username, from: from.toISOString(), to: to.toISOString() } }),
|
|
});
|
|
if (!res.ok) return null;
|
|
const json = await res.json();
|
|
const daysArr = json?.data?.user?.contributionsCollection?.contributionCalendar?.weeks?.flatMap(w => w.contributionDays) || [];
|
|
const counts = {};
|
|
for (const d of daysArr) {
|
|
if (d?.date) counts[d.date] = (counts[d.date] || 0) + (d.contributionCount || 0);
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
async function fetchGitHubEvents({ baseUrl = 'https://api.github.com', username, token }, days = 365) {
|
|
// Prefer GraphQL for full-year coverage if token present
|
|
try {
|
|
if (token) {
|
|
const graphCounts = await fetchGitHubGraphQL({ baseUrl, username, token }, days);
|
|
if (graphCounts) return graphCounts;
|
|
}
|
|
} catch {}
|
|
const headers = { 'Accept': 'application/vnd.github+json' };
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
const counts = {};
|
|
for (let page = 1; page <= 3; page++) {
|
|
const url = `${baseUrl.replace(/\/$/, '')}/users/${encodeURIComponent(username)}/events?per_page=100&page=${page}`;
|
|
const res = await fetch(url, { headers });
|
|
if (!res.ok) break;
|
|
const events = await res.json();
|
|
if (!Array.isArray(events) || events.length === 0) break;
|
|
for (const ev of events) {
|
|
if (!ev || !ev.created_at) continue;
|
|
if (isOlderThan(ev.created_at, days)) { page = 999; break; }
|
|
if (ev.type === 'PushEvent') {
|
|
const c = ev.payload?.size || 1;
|
|
const day = formatDate(new Date(ev.created_at));
|
|
counts[day] = (counts[day] || 0) + c;
|
|
}
|
|
}
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
async function fetchGiteaLike({ baseUrl, username, token }, days = 365) {
|
|
let authMode = token ? 'token' : null; // 'token' | 'bearer' | null
|
|
const counts = {};
|
|
for (let page = 1; page <= 8; page++) {
|
|
const url = `${baseUrl.replace(/\/$/, '')}/api/v1/users/${encodeURIComponent(username)}/events?limit=50&page=${page}`;
|
|
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 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;
|
|
}
|
|
|
|
async function fetchGitLabEvents({ baseUrl = 'https://gitlab.com', token }, days = 365) {
|
|
const headers = { 'Accept': 'application/json', 'PRIVATE-TOKEN': token };
|
|
const counts = {};
|
|
for (let page = 1; page <= 5; page++) {
|
|
const url = `${baseUrl.replace(/\/$/, '')}/api/v4/events?per_page=100&page=${page}&action=push`;
|
|
const res = await fetch(url, { headers });
|
|
if (!res.ok) break;
|
|
const events = await res.json();
|
|
if (!Array.isArray(events) || events.length === 0) break;
|
|
for (const ev of events) {
|
|
const created = ev?.created_at;
|
|
if (!created) continue;
|
|
if (isOlderThan(created, days)) { page = 999; break; }
|
|
const day = formatDate(new Date(created));
|
|
counts[day] = (counts[day] || 0) + 1;
|
|
}
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
export async function fetchAllGitActivity({ force = false, days = 365 } = {}) {
|
|
const { lastSync, dailyCounts } = getCache();
|
|
const last = lastSync ? new Date(lastSync) : null;
|
|
const now = new Date();
|
|
const withinDay = last && (now - last) < 24 * 60 * 60 * 1000;
|
|
if (!force && withinDay && dailyCounts) {
|
|
return { dailyCounts, lastSync };
|
|
}
|
|
|
|
const integrations = getIntegrations();
|
|
const perSource = [];
|
|
for (const src of integrations) {
|
|
const token = await decryptToken(src.tokenEnc);
|
|
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));
|
|
} else if (src.provider === 'gitlab') {
|
|
perSource.push(await fetchGitLabEvents(info, days));
|
|
} else if (src.provider === 'gitea' || src.provider === 'forgejo' || src.provider === 'custom') {
|
|
perSource.push(await fetchGiteaLike(info, days));
|
|
}
|
|
} catch (e) {
|
|
// Continue other sources
|
|
console.warn('Git fetch failed for', src.provider, e);
|
|
}
|
|
}
|
|
// Merge
|
|
const merged = {};
|
|
for (const m of perSource) {
|
|
for (const [day, cnt] of Object.entries(m)) {
|
|
merged[day] = (merged[day] || 0) + cnt;
|
|
}
|
|
}
|
|
const updated = { lastSync: new Date().toISOString(), dailyCounts: merged };
|
|
setCache(updated);
|
|
return updated;
|
|
}
|