diff --git a/CLAUDE.md b/CLAUDE.md index 0d9388f..687e24f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,9 +13,10 @@ macOS CLI tool that syncs GitHub PRs and Issues to Things 3 task manager. Uses p pnpm build # Dev mode (run TypeScript directly via tsx) -pnpm dev -- -pnpm dev -- sync -v -pnpm dev -- config --verify +pnpm dev +pnpm dev sync -v +pnpm dev config --verify +pnpm dev config --repos=prompt # Type checking pnpm typecheck @@ -63,6 +64,8 @@ The daemon uses four search queries (configurable via sync-types): - `is:issue is:open assignee:@me` (issues-assigned) - `is:issue is:open author:@me` (issues-created) +Results are filtered by repository scope (configurable via `--repos=prompt` or in init wizard). + ## Publishing ```bash diff --git a/README.md b/README.md index dac160e..3fc7ffc 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ github-things-sync start - šŸ”„ **Auto-sync** — PRs and issues appear in Things automatically - āœ… **Completion tracking** — Close an issue, task completes in Things - šŸ“‹ **Smart filtering** — Choose what to sync (reviews, created, assigned) +- šŸŽÆ **Repository scope** — Select specific repos or orgs to sync - šŸš€ **Background daemon** — Runs silently, syncs every 5 minutes - šŸŽ **macOS native** — Uses LaunchAgent for autostart @@ -125,6 +126,8 @@ Update settings with: github-things-sync config --interval=600 # Poll every 10 minutes github-things-sync config --project="Work" # Use different Things project github-things-sync config --sync-types=pr-reviews,issues-assigned +github-things-sync config --repos=prompt # Select specific repos to sync +github-things-sync config --repos=all # Reset to sync all repos ``` ## Development @@ -133,7 +136,7 @@ github-things-sync config --sync-types=pr-reviews,issues-assigned git clone https://github.com/yungweng/github-things-sync.git cd github-things-sync pnpm install -pnpm dev -- sync -v # Run sync in dev mode +pnpm dev sync -v # Run sync in dev mode ``` ## Contributing diff --git a/package.json b/package.json index 1575acb..0a69fc6 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "chalk": "^5.6.2", "commander": "^12.0.0", "open": "^10.0.0", + "ora": "^9.0.0", "update-notifier": "^7.3.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4298e3f..50aaf64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: open: specifier: ^10.0.0 version: 10.2.0 + ora: + specifier: ^9.0.0 + version: 9.0.0 update-notifier: specifier: ^7.3.1 version: 7.3.1 @@ -662,6 +665,14 @@ packages: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@3.3.0: + resolution: {integrity: sha512-/+40ljC3ONVnYIttjMWrlL51nItDAbBrq2upN8BPyvGU/2n5Oxw3tbNwORCaNuNqLJnxGqOfjUuhsv7l5Q4IsQ==} + engines: {node: '>=18.20'} + cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -803,6 +814,10 @@ packages: resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==} engines: {node: '>=18'} + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + is-npm@6.1.0: resolution: {integrity: sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -811,6 +826,10 @@ packages: resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} engines: {node: '>=12'} + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-wsl@3.1.0: resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} engines: {node: '>=16'} @@ -838,9 +857,17 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -861,10 +888,18 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + open@10.2.0: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} + ora@9.0.0: + resolution: {integrity: sha512-m0pg2zscbYgWbqRR6ABga5c3sZdEon7bSgjnlXC64kxtxLOyjRcbbUkLj7HFyy/FTD+P2xdBWu8snGhYI0jc4A==} + engines: {node: '>=20'} + package-json@10.0.1: resolution: {integrity: sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==} engines: {node: '>=18'} @@ -934,6 +969,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + rollup@4.54.0: resolution: {integrity: sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -959,6 +998,10 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -967,6 +1010,10 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string-width@8.1.0: + resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==} + engines: {node: '>=20'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -1076,6 +1123,10 @@ packages: resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} engines: {node: '>=12'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + snapshots: '@biomejs/biome@2.3.10': @@ -1525,6 +1576,12 @@ snapshots: cli-boxes@3.0.0: {} + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@3.3.0: {} + cli-width@4.1.0: {} commander@12.1.0: {} @@ -1653,10 +1710,14 @@ snapshots: global-directory: 4.0.1 is-path-inside: 4.0.0 + is-interactive@2.0.0: {} + is-npm@6.1.0: {} is-path-inside@4.0.0: {} + is-unicode-supported@2.1.0: {} + is-wsl@3.1.0: dependencies: is-inside-container: 1.0.0 @@ -1675,10 +1736,17 @@ snapshots: load-tsconfig@0.2.5: {} + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mimic-function@5.0.1: {} + minimist@1.2.8: {} mlly@1.8.0: @@ -1700,6 +1768,10 @@ snapshots: object-assign@4.1.1: {} + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + open@10.2.0: dependencies: default-browser: 5.4.0 @@ -1707,6 +1779,18 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 + ora@9.0.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.3.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.2.2 + string-width: 8.1.0 + strip-ansi: 7.1.2 + package-json@10.0.1: dependencies: ky: 1.14.2 @@ -1761,6 +1845,11 @@ snapshots: resolve-pkg-maps@1.0.0: {} + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + rollup@4.54.0: dependencies: '@types/estree': 1.0.8 @@ -1799,6 +1888,8 @@ snapshots: source-map@0.7.6: {} + stdin-discarder@0.2.2: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -1811,6 +1902,11 @@ snapshots: get-east-asian-width: 1.4.0 strip-ansi: 7.1.2 + string-width@8.1.0: + dependencies: + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.2 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -1930,3 +2026,5 @@ snapshots: is-wsl: 3.1.0 xdg-basedir@5.1.0: {} + + yoctocolors@2.1.2: {} diff --git a/src/cli/commands/config.ts b/src/cli/commands/config.ts index 5c70296..5e2b23c 100644 --- a/src/cli/commands/config.ts +++ b/src/cli/commands/config.ts @@ -5,13 +5,16 @@ import { password } from "@inquirer/prompts"; import { Octokit } from "@octokit/rest"; import chalk from "chalk"; +import ora from "ora"; import { installLaunchAgent, uninstallLaunchAgent, } from "../../daemon/launchagent.js"; +import { GitHubClient } from "../../github/client.js"; import { loadConfig, saveConfig } from "../../state/config.js"; import type { Config, SyncType } from "../../types/index.js"; import { ALL_SYNC_TYPES } from "../../types/index.js"; +import { formatRepoFilter, selectRepos } from "../repo-selector.js"; interface ConfigOptions { interval?: string; @@ -20,6 +23,7 @@ interface ConfigOptions { githubToken?: string; thingsToken?: string; syncTypes?: string; + repos?: string; verify?: boolean; show?: boolean; } @@ -33,8 +37,19 @@ export async function configCommand(options: ConfigOptions): Promise { process.exit(1); } - // Show current config - if (options.show || Object.keys(options).length === 0) { + // Check if any update option was provided + const hasUpdateOption = + options.interval !== undefined || + options.autostart !== undefined || + options.project !== undefined || + options.githubToken !== undefined || + options.thingsToken !== undefined || + options.syncTypes !== undefined || + options.repos !== undefined || + options.verify; + + // Show current config if no update options provided + if (options.show || !hasUpdateOption) { await showConfig(config); return; } @@ -141,6 +156,49 @@ export async function configCommand(options: ConfigOptions): Promise { changed = true; } + if (options.repos !== undefined) { + if (options.repos.toLowerCase() === "all") { + config.repoFilter = { mode: "all", repos: [] }; + console.log(chalk.green("āœ… Repository scope set to: all repositories")); + changed = true; + } else if (options.repos === "prompt") { + const spinner = ora("Fetching your repositories...").start(); + try { + const client = new GitHubClient(config.githubToken); + const groupedRepos = await client.fetchAllRepos(); + spinner.stop(); + + const totalRepos = Object.values(groupedRepos).reduce( + (sum, repos) => sum + repos.length, + 0, + ); + const ownerCount = Object.keys(groupedRepos).length; + console.log( + chalk.dim( + `Found ${totalRepos} repositories across ${ownerCount} owner${ownerCount === 1 ? "" : "s"}.\n`, + ), + ); + + config.repoFilter = await selectRepos(groupedRepos, config.repoFilter); + console.log( + chalk.green( + `āœ… Repository scope set to: ${formatRepoFilter(config.repoFilter)}`, + ), + ); + changed = true; + } catch (error) { + spinner.fail("Failed to fetch repositories"); + console.error(chalk.red(`āŒ ${error}`)); + process.exit(1); + } + } else { + console.error( + chalk.red("āŒ Use --repos=all or --repos=prompt to configure repos"), + ); + process.exit(1); + } + } + if (changed) { saveConfig(config); console.log( @@ -168,6 +226,9 @@ async function showConfig(config: Config): Promise { chalk.dim("Sync types: ") + (config.syncTypes?.join(", ") || "all (default)"), ); + console.log( + chalk.dim("Repo scope: ") + formatRepoFilter(config.repoFilter), + ); console.log(""); console.log(chalk.bold("Tokens")); console.log(chalk.dim("──────")); @@ -191,6 +252,9 @@ async function showConfig(config: Config): Promise { chalk.dim(" --sync-types=TYPES ") + 'Set sync types (comma-separated or "all")', ); + console.log( + `${chalk.dim(" --repos=all|prompt ")}Set repo filter (all or select)`, + ); console.log(`${chalk.dim(" --verify ")}Verify tokens work`); console.log(""); } diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 5c35385..82af1aa 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -4,15 +4,18 @@ import { checkbox, confirm, input, password } from "@inquirer/prompts"; import chalk from "chalk"; +import ora from "ora"; import { installLaunchAgent } from "../../daemon/launchagent.js"; +import { GitHubClient } from "../../github/client.js"; import { getConfigPath, getDataDir, loadConfig, saveConfig, } from "../../state/config.js"; -import type { Config, SyncType } from "../../types/index.js"; +import type { Config, RepoFilter, SyncType } from "../../types/index.js"; import { ALL_SYNC_TYPES } from "../../types/index.js"; +import { formatRepoFilter, selectRepos } from "../repo-selector.js"; function maskToken(token: string): string { if (token.length <= 8) return "****"; @@ -39,6 +42,9 @@ export async function initCommand(): Promise { chalk.dim(" Sync Types: ") + (existing.syncTypes?.join(", ") || "all"), ); + console.log( + chalk.dim(" Repo Scope: ") + formatRepoFilter(existing.repoFilter), + ); console.log( chalk.dim(" Autostart: ") + (existing.autoStart ? "yes" : "no") + @@ -197,8 +203,43 @@ export async function initCommand(): Promise { }, }); - // Step 6: Auto-start - console.log(chalk.bold("\nStep 6: Auto-start")); + // Step 6: Repository Scope + console.log(chalk.bold("\nStep 6: Repository Scope")); + console.log(chalk.dim("────────────────────────")); + console.log(chalk.dim("Which repositories should be synced to Things?\n")); + + const spinner = ora("Fetching your repositories...").start(); + let repoFilter: RepoFilter = { mode: "all", repos: [] }; + + try { + const client = new GitHubClient(githubToken); + const groupedRepos = await client.fetchAllRepos(); + spinner.stop(); + + const totalRepos = Object.values(groupedRepos).reduce( + (sum, repos) => sum + repos.length, + 0, + ); + const ownerCount = Object.keys(groupedRepos).length; + console.log( + chalk.dim( + `Found ${totalRepos} repositories across ${ownerCount} owner${ownerCount === 1 ? "" : "s"}.\n`, + ), + ); + + repoFilter = await selectRepos(groupedRepos, existing?.repoFilter); + } catch (error) { + spinner.fail("Failed to fetch repositories"); + console.log( + chalk.yellow( + "āš ļø Could not fetch repositories. Defaulting to sync all repos.", + ), + ); + console.log(chalk.dim(` Error: ${error}\n`)); + } + + // Step 7: Auto-start + console.log(chalk.bold("\nStep 7: Auto-start")); console.log(chalk.dim("──────────────────")); console.log(chalk.dim("Start automatically when you log in to your Mac?\n")); @@ -215,6 +256,7 @@ export async function initCommand(): Promise { pollInterval, autoStart, syncTypes, + repoFilter, }; saveConfig(config); diff --git a/src/cli/index.ts b/src/cli/index.ts index 9d8dbf6..c250511 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -124,6 +124,10 @@ program 'Update Things token (use "prompt" for interactive)', ) .option("--sync-types ", 'Set sync types (comma-separated or "all")') + .option( + "--repos ", + 'Set repo filter ("all" or "prompt" for interactive)', + ) .option("--verify", "Verify tokens work") .action(configCommand); diff --git a/src/cli/repo-selector.ts b/src/cli/repo-selector.ts new file mode 100644 index 0000000..b67a0f2 --- /dev/null +++ b/src/cli/repo-selector.ts @@ -0,0 +1,151 @@ +/** + * Interactive repository selector UI + */ + +import { checkbox, select } from "@inquirer/prompts"; +import chalk from "chalk"; +import type { GroupedRepos, RepoInfo } from "../github/client.js"; +import type { RepoFilter } from "../types/index.js"; + +interface OwnerChoice { + name: string; + value: string; + checked: boolean; +} + +/** + * Interactive two-step repo selector + * 1. Select orgs/owners to include + * 2. For each selected owner, pick repos + */ +export async function selectRepos( + groupedRepos: GroupedRepos, + existing?: RepoFilter, +): Promise { + // First, ask if they want all repos or specific ones + const scopeChoice = await select({ + message: "Which repositories should be synced?", + choices: [ + { + name: "All repositories (default)", + value: "all", + }, + { + name: "Choose specific repositories", + value: "selected", + }, + ], + default: existing?.mode === "selected" ? "selected" : "all", + }); + + if (scopeChoice === "all") { + return { mode: "all", repos: [] }; + } + + // Get owners sorted alphabetically + const owners = Object.keys(groupedRepos).sort((a, b) => + a.toLowerCase().localeCompare(b.toLowerCase()), + ); + + if (owners.length === 0) { + console.log(chalk.yellow("No repositories found.")); + return { mode: "all", repos: [] }; + } + + // Build owner choices with repo counts + const ownerChoices: OwnerChoice[] = owners.map((owner) => { + const repoCount = groupedRepos[owner].length; + const hasSelectedRepos = + existing?.mode === "selected" && + groupedRepos[owner].some((r) => existing.repos.includes(r.fullName)); + + return { + name: `${owner} (${repoCount} repo${repoCount === 1 ? "" : "s"})`, + value: owner, + checked: hasSelectedRepos || existing?.mode !== "selected", + }; + }); + + // Step 1: Select owners/orgs + console.log(""); + const selectedOwners = await checkbox({ + message: "Select organizations/owners to include", + choices: ownerChoices, + pageSize: 15, + validate: (value) => { + if (value.length === 0) { + return "Select at least one organization/owner"; + } + return true; + }, + }); + + // Step 2: For each selected owner, pick repos + const selectedRepos: string[] = []; + + for (const owner of selectedOwners) { + const repos = groupedRepos[owner]; + + // Build repo choices + const repoChoices = repos.map((repo: RepoInfo) => { + const isExistingSelected = + existing?.mode === "selected" && existing.repos.includes(repo.fullName); + const label = repo.isPrivate + ? `${repo.name} ${chalk.dim("(private)")}` + : repo.name; + + return { + name: label, + value: repo.fullName, + checked: isExistingSelected || existing?.mode !== "selected", + }; + }); + + console.log(""); + const ownerRepos = await checkbox({ + message: `Select repositories from ${chalk.cyan(owner)}`, + choices: repoChoices, + pageSize: 15, + }); + + selectedRepos.push(...ownerRepos); + } + + if (selectedRepos.length === 0) { + console.log( + chalk.yellow("\nāš ļø No repositories selected. Defaulting to all repos."), + ); + return { mode: "all", repos: [] }; + } + + console.log( + chalk.dim( + `\n${selectedRepos.length} repositor${selectedRepos.length === 1 ? "y" : "ies"} selected.`, + ), + ); + + return { + mode: "selected", + repos: selectedRepos.sort(), + }; +} + +/** + * Format repo filter for display + */ +export function formatRepoFilter(filter?: RepoFilter): string { + if (!filter || filter.mode === "all") { + return "all repositories"; + } + + const count = filter.repos.length; + if (count === 0) { + return "all repositories"; + } + + if (count <= 3) { + return filter.repos.join(", "); + } + + return `${count} repositories`; +} diff --git a/src/daemon/sync.ts b/src/daemon/sync.ts index b21aca5..dd18119 100644 --- a/src/daemon/sync.ts +++ b/src/daemon/sync.ts @@ -32,7 +32,11 @@ export async function runSync( errors: [], }; - const github = new GitHubClient(config.githubToken, config.syncTypes); + const github = new GitHubClient( + config.githubToken, + config.syncTypes, + config.repoFilter, + ); const things = new ThingsClient(config.thingsProject, config.thingsAuthToken); try { diff --git a/src/github/client.ts b/src/github/client.ts index 77f03a6..5c440c2 100644 --- a/src/github/client.ts +++ b/src/github/client.ts @@ -3,14 +3,31 @@ */ import { Octokit } from "@octokit/rest"; -import type { GitHubItem, GitHubItemType, SyncType } from "../types/index.js"; +import type { + GitHubItem, + GitHubItemType, + RepoFilter, + SyncType, +} from "../types/index.js"; + +export interface RepoInfo { + fullName: string; // "owner/repo" + name: string; + owner: string; + isPrivate: boolean; +} + +export interface GroupedRepos { + [owner: string]: RepoInfo[]; +} export class GitHubClient { private octokit: Octokit; private username: string | null = null; private syncTypes: SyncType[]; + private repoFilter?: RepoFilter; - constructor(token: string, syncTypes?: SyncType[]) { + constructor(token: string, syncTypes?: SyncType[], repoFilter?: RepoFilter) { this.octokit = new Octokit({ auth: token }); this.syncTypes = syncTypes || [ "pr-reviews", @@ -18,6 +35,7 @@ export class GitHubClient { "issues-assigned", "issues-created", ]; + this.repoFilter = repoFilter; } async getUsername(): Promise { @@ -28,6 +46,62 @@ export class GitHubClient { return this.username; } + /** + * Fetch all repositories the user has access to, grouped by owner + */ + async fetchAllRepos(): Promise { + const repos: RepoInfo[] = []; + let page = 1; + + // Paginate through all repos + while (true) { + const { data } = await this.octokit.repos.listForAuthenticatedUser({ + per_page: 100, + page, + sort: "full_name", + }); + + if (data.length === 0) break; + + for (const repo of data) { + repos.push({ + fullName: repo.full_name, + name: repo.name, + owner: repo.owner?.login || "unknown", + isPrivate: repo.private, + }); + } + + page++; + } + + // Group by owner + const grouped: GroupedRepos = {}; + for (const repo of repos) { + if (!grouped[repo.owner]) { + grouped[repo.owner] = []; + } + grouped[repo.owner].push(repo); + } + + // Sort repos within each owner + for (const owner of Object.keys(grouped)) { + grouped[owner].sort((a, b) => a.name.localeCompare(b.name)); + } + + return grouped; + } + + /** + * Check if a repo should be included based on filter + */ + private shouldIncludeRepo(repoFullName: string): boolean { + if (!this.repoFilter || this.repoFilter.mode === "all") { + return true; + } + return this.repoFilter.repos.includes(repoFullName); + } + /** * Fetch all items we care about from GitHub */ @@ -57,7 +131,8 @@ export class GitHubClient { items.push(...result); } - return items; + // Filter by repo if configured + return items.filter((item) => this.shouldIncludeRepo(item.repo)); } /** diff --git a/src/types/index.ts b/src/types/index.ts index 66ae3e3..06080e7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -55,6 +55,12 @@ export interface SyncState { lastError: string | null; } +// Repository filter configuration +export interface RepoFilter { + mode: "all" | "selected"; + repos: string[]; // Format: "owner/repo" +} + // User configuration export interface Config { githubToken: string; @@ -63,6 +69,7 @@ export interface Config { pollInterval: number; // Seconds, default: 300 (5 min) autoStart: boolean; // Install LaunchAgent syncTypes: SyncType[]; // Which item types to sync (default: all) + repoFilter?: RepoFilter; // Which repos to sync (undefined = all) } // Daemon status