/** * Unified view combining local projects and GitHub repos */ import type { Project, GitHubRepoInfo, UnifiedRepo, ViewMode, QuickFilter, } from "../types/index.ts"; import { PROJECT_MARKERS } from "../types/index.ts"; import { defaultGitHubService } from "../services/github.ts"; import { parseGitHubUrl } from "./cli.ts"; import { errorToString } from "../utils/errors.ts"; import { bunGitService } from "../services/index.ts"; import type { GitService } from "../services/git.ts"; import { existsSync } from "fs"; /** * Extract owner/repo from a git remote URL */ function extractRepoIdentifier(remoteUrl: string | null): string | null { if (!remoteUrl) return null; const parsed = parseGitHubUrl(remoteUrl); if (parsed) { return `${parsed.owner}/${parsed.repo}`.toLowerCase(); } return null; } /** * Create a unified view by matching local projects with GitHub repos */ export function createUnifiedView( localProjects: Project[], githubRepos: GitHubRepoInfo[] ): UnifiedRepo[] { const unified: UnifiedRepo[] = []; const matchedGitHubIds = new Set(); // First, process local projects and try to match with GitHub for (const local of localProjects) { const remoteId = extractRepoIdentifier(local.status?.remoteUrl ?? null); let matchedGitHub: GitHubRepoInfo | null = null; if (remoteId) { // Try to find matching GitHub repo matchedGitHub = githubRepos.find( (gh) => gh.fullName.toLowerCase() === remoteId ) ?? null; if (matchedGitHub) { matchedGitHubIds.add(matchedGitHub.fullName); } } unified.push({ id: local.id, name: local.name, source: matchedGitHub ? "both" : "local", local, github: matchedGitHub, isCloned: true, isOnGitHub: matchedGitHub !== null, localPath: local.path, }); } // Add GitHub repos that aren't cloned locally for (const github of githubRepos) { if (!matchedGitHubIds.has(github.fullName)) { unified.push({ id: `github-${github.fullName}`, name: github.name, source: "github", local: null, github, isCloned: false, isOnGitHub: true, localPath: null, }); } } return unified; } /** * Filter unified repos based on view mode */ export function filterByViewMode( repos: UnifiedRepo[], mode: ViewMode ): UnifiedRepo[] { switch (mode) { case "local": // Show repos that exist locally (local-only or synced) return repos.filter((r) => r.isCloned); case "github": // Show repos that exist on GitHub (github-only or synced) return repos.filter((r) => r.isOnGitHub); case "combined": return repos; } } /** Return GitHub language, or the best local marker-derived equivalent. */ export function getUnifiedRepoLanguage(repo: UnifiedRepo): string | null { if (repo.github?.language) return repo.github.language; const marker = repo.local?.projectMarker; return marker ? PROJECT_MARKERS[marker] ?? marker : null; } /** Apply the numbered quick filters consistently in every unified view. */ export function filterUnifiedReposByQuickFilter( repos: UnifiedRepo[], quickFilter: QuickFilter, ): UnifiedRepo[] { if (quickFilter === "all") return repos; return repos.filter((repo) => { switch (quickFilter) { case "dirty": return repo.local?.status?.isDirty; case "unpushed": return repo.local?.status?.isAhead && repo.local.status.unpushedCommits > 0; case "no-remote": return repo.local?.type === "git" && !repo.local.status?.hasRemote; case "github-only": return repo.source === "github"; case "local-only": return repo.source === "local"; case "private": return repo.github?.isPrivate === true; case "public": return repo.github?.isPrivate === false; case "archived": return repo.github?.isArchived === true; case "forks": return repo.github?.isFork === true; default: return true; } }); } /** * Sort unified repos */ export function sortUnifiedRepos( repos: UnifiedRepo[], sortBy: "status" | "name" | "branch" | "sync" | "language" | "stars" | "forks" | "lastActivity" | "size", direction: "asc" | "desc" ): UnifiedRepo[] { return [...repos].sort((a, b) => { let comparison = 0; switch (sortBy) { case "name": comparison = a.name.localeCompare(b.name); break; case "branch": { const aBranch = a.local?.status?.currentBranch ?? ""; const bBranch = b.local?.status?.currentBranch ?? ""; comparison = aBranch.localeCompare(bBranch); break; } case "status": // Prioritize: GitHub-only (not cloned) > dirty > ahead/behind > clean const aPriority = getUnifiedPriority(a); const bPriority = getUnifiedPriority(b); comparison = aPriority - bPriority; return direction === "desc" ? comparison : -comparison; case "sync": { // Higher sync delta first when desc const aStatus = a.local?.status; const bStatus = b.local?.status; const aDelta = aStatus ? (aStatus.unpushedCommits ?? 0) + (aStatus.unpulledCommits ?? 0) : 0; const bDelta = bStatus ? (bStatus.unpushedCommits ?? 0) + (bStatus.unpulledCommits ?? 0) : 0; comparison = aDelta - bDelta; break; } case "language": { const aLang = (getUnifiedRepoLanguage(a) || "").toLowerCase(); const bLang = (getUnifiedRepoLanguage(b) || "").toLowerCase(); comparison = aLang.localeCompare(bLang); break; } case "lastActivity": const aDate = getLastActivity(a); const bDate = getLastActivity(b); comparison = aDate - bDate; break; case "stars": comparison = (a.github?.stargazersCount ?? 0) - (b.github?.stargazersCount ?? 0); break; case "forks": comparison = (a.github?.forksCount ?? 0) - (b.github?.forksCount ?? 0); break; case "size": comparison = (a.github?.size ?? 0) - (b.github?.size ?? 0); break; } return direction === "desc" ? -comparison : comparison; }); } /** * Get priority for sorting (lower = more attention needed) */ function getUnifiedPriority(repo: UnifiedRepo): number { // GitHub-only (not cloned) - highest priority if (!repo.isCloned && repo.isOnGitHub) return -100; // Local only (not on GitHub) - needs remote setup if (repo.isCloned && !repo.isOnGitHub) return -50; const local = repo.local; if (!local?.status) return 50; let priority = 0; // Dirty repos need attention if (local.status.isDirty) priority -= 40; // Out of sync repos need attention if (local.status.isAhead) priority -= 20; if (local.status.isBehind) priority -= 30; // No remote = might need setup if (!local.status.hasRemote) priority -= 10; return priority; } /** * Get timestamp from a Date object or ISO string */ function getTimestamp(date: Date | string | null | undefined): number { if (!date) return 0; if (typeof date === 'string') { const parsed = new Date(date); return isNaN(parsed.getTime()) ? 0 : parsed.getTime(); } return date.getTime(); } /** * Get last activity timestamp for sorting */ function getLastActivity(repo: UnifiedRepo): number { // Prefer local commit date if available if (repo.local?.status?.lastLocalCommit) { return getTimestamp(repo.local.status.lastLocalCommit); } // Fall back to GitHub pushed_at if (repo.github?.pushedAt) { return getTimestamp(repo.github.pushedAt); } return 0; } /** * Filter unified repos by text search */ export function filterUnifiedRepos( repos: UnifiedRepo[], filterText: string ): UnifiedRepo[] { if (!filterText.trim()) return repos; const lower = filterText.toLowerCase(); return repos.filter((r) => { // Match name if (r.name.toLowerCase().includes(lower)) return true; // Match local path if (r.localPath?.toLowerCase().includes(lower)) return true; // Match GitHub full name if (r.github?.fullName.toLowerCase().includes(lower)) return true; // Match description if (r.github?.description?.toLowerCase().includes(lower)) return true; // Match language if (getUnifiedRepoLanguage(r)?.toLowerCase().includes(lower)) return true; // Match source type if (r.source.includes(lower)) return true; return false; }); } /** * Fetch GitHub repos and create unified view */ export async function fetchUnifiedRepos( localProjects: Project[], options?: { includeOrgs?: boolean; includeArchived?: boolean; includeForks?: boolean; } ): Promise<{ unified: UnifiedRepo[]; githubRepos: GitHubRepoInfo[]; error?: string; }> { if (!defaultGitHubService.hasToken()) { return { unified: localProjects.map((p) => ({ id: p.id, name: p.name, source: "local" as const, local: p, github: null, isCloned: true, isOnGitHub: false, localPath: p.path, })), githubRepos: [], error: "GITHUB_TOKEN not set. Run: export GITHUB_TOKEN=your_token", }; } try { const githubRepos = await defaultGitHubService.getAllRepos(options); const unified = createUnifiedView(localProjects, githubRepos); return { unified, githubRepos }; } catch (error) { return { unified: localProjects.map((p) => ({ id: p.id, name: p.name, source: "local" as const, local: p, github: null, isCloned: true, isOnGitHub: false, localPath: p.path, })), githubRepos: [], error: errorToString(error), }; } } /** * Clone a GitHub repo to a target directory */ export async function cloneGitHubRepo( repo: UnifiedRepo, targetDir: string, useSSH = true, gitService: GitService = bunGitService ): Promise<{ success: boolean; path?: string; error?: string }> { if (!repo.github) { return { success: false, error: "No GitHub info available for this repo" }; } // Check if target directory exists if (!existsSync(targetDir)) { return { success: false, error: `Target directory does not exist: ${targetDir}` }; } const repoPath = `${targetDir}/${repo.name}`; const url = useSSH ? repo.github.sshUrl : repo.github.cloneUrl; try { const result = await gitService.clone(url, repoPath); if (result.success) { return { success: true, path: repoPath }; } return { success: false, error: result.error || "Clone failed" }; } catch (error) { return { success: false, error: errorToString(error), }; } } /** * Get statistics about the unified view */ export function getUnifiedStats(repos: UnifiedRepo[]): { total: number; localOnly: number; githubOnly: number; both: number; dirty: number; unpushed: number; unpulled: number; } { return { total: repos.length, localOnly: repos.filter((r) => r.source === "local").length, githubOnly: repos.filter((r) => r.source === "github").length, both: repos.filter((r) => r.source === "both").length, dirty: repos.filter((r) => r.local?.status?.isDirty).length, unpushed: repos.filter((r) => r.local?.status?.isAhead).length, unpulled: repos.filter((r) => r.local?.status?.isBehind).length, }; }