edsger 0.61.0 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ /**
2
+ * CLI command: `edsger sync-org-repos <teamId>`
3
+ *
4
+ * Reads the team's configured github_org, fetches all repos from that org
5
+ * via the local `gh` CLI, and creates a product for each repo not yet linked.
6
+ */
7
+ export interface SyncOrgReposCliOptions {
8
+ verbose?: boolean;
9
+ org?: string;
10
+ }
11
+ export declare function runSyncOrgRepos(teamId: string, options?: SyncOrgReposCliOptions): Promise<void>;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * CLI command: `edsger sync-org-repos <teamId>`
3
+ *
4
+ * Reads the team's configured github_org, fetches all repos from that org
5
+ * via the local `gh` CLI, and creates a product for each repo not yet linked.
6
+ */
7
+ import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
8
+ import { syncOrgRepos } from '../../phases/sync-org-repos/index.js';
9
+ import { logError, logInfo, logSuccess } from '../../utils/logger.js';
10
+ const ORG_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
11
+ export async function runSyncOrgRepos(teamId, options = {}) {
12
+ const { verbose } = options;
13
+ if (!hasSupabaseSession()) {
14
+ logError('Supabase session unavailable. Sign in to the Edsger desktop app to authorize the CLI.');
15
+ process.exit(1);
16
+ }
17
+ const supabase = getSupabase();
18
+ let orgLogin = options.org;
19
+ if (!orgLogin) {
20
+ const { data: team, error } = await supabase
21
+ .from('teams')
22
+ .select('github_org')
23
+ .eq('id', teamId)
24
+ .single();
25
+ if (error || !team) {
26
+ logError(`Team not found: ${teamId}`);
27
+ process.exit(1);
28
+ }
29
+ orgLogin = team.github_org ?? undefined;
30
+ if (!orgLogin) {
31
+ logError('No GitHub organization configured for this team. Set it in team settings first, or pass --org <name>.');
32
+ process.exit(1);
33
+ }
34
+ }
35
+ if (!ORG_NAME_RE.test(orgLogin) || orgLogin.length > 39) {
36
+ logError(`Invalid GitHub organization name: "${orgLogin}"`);
37
+ process.exit(1);
38
+ }
39
+ const { data: { user }, } = await supabase.auth.getUser();
40
+ if (!user) {
41
+ logError('Not authenticated');
42
+ process.exit(1);
43
+ }
44
+ logInfo(`Syncing repos from org "${orgLogin}" into team ${teamId}`);
45
+ const result = await syncOrgRepos({
46
+ teamId,
47
+ orgLogin,
48
+ userId: user.id,
49
+ verbose,
50
+ });
51
+ if (result.status === 'success') {
52
+ logSuccess(result.message);
53
+ logInfo(`Total: ${result.total} · created: ${result.created} · skipped: ${result.skipped}`);
54
+ }
55
+ else {
56
+ logError(result.message);
57
+ process.exit(1);
58
+ }
59
+ }
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ import { runRunSheetCommand } from './commands/run-sheet/index.js';
34
34
  import { runScreenFlow } from './commands/screen-flow/index.js';
35
35
  import { runSmokeTestCommand } from './commands/smoke-test/index.js';
36
36
  import { runSyncGithubIssues } from './commands/sync-github-issues/index.js';
37
+ import { runSyncOrgRepos } from './commands/sync-org-repos/index.js';
37
38
  import { runSyncAws } from './commands/sync-aws/index.js';
38
39
  import { runSyncDatadog } from './commands/sync-datadog/index.js';
39
40
  import { runSyncTerraform } from './commands/sync-terraform/index.js';
@@ -601,6 +602,23 @@ program
601
602
  }
602
603
  });
603
604
  // ============================================================
605
+ // Subcommand: edsger sync-org-repos <teamId>
606
+ // ============================================================
607
+ program
608
+ .command('sync-org-repos <teamId>')
609
+ .description("Sync a GitHub org's repos as products under a team. Uses the local gh CLI.")
610
+ .option('-v, --verbose', 'Verbose output')
611
+ .option('--org <name>', 'GitHub org name (defaults to the team\'s configured github_org)')
612
+ .action(async (teamId, opts) => {
613
+ try {
614
+ await runSyncOrgRepos(teamId, opts);
615
+ }
616
+ catch (error) {
617
+ logError(error instanceof Error ? error.message : String(error));
618
+ process.exit(1);
619
+ }
620
+ });
621
+ // ============================================================
604
622
  // Subcommand: edsger sync-sentry-issues <productId>
605
623
  //
606
624
  // Requires SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT in env (set via
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Phase: sync-org-repos
3
+ *
4
+ * Fetches all repositories from a GitHub organization using the local `gh` CLI,
5
+ * then creates a product for each repo that isn't already linked to one
6
+ * within the same team.
7
+ *
8
+ * Uses `gh api --paginate` for truly unlimited pagination (no hardcoded cap).
9
+ * Forks and archived repos are filtered out client-side.
10
+ */
11
+ export interface SyncOrgReposResult {
12
+ status: 'success' | 'error';
13
+ message: string;
14
+ total: number;
15
+ created: number;
16
+ skipped: number;
17
+ repos?: string[];
18
+ }
19
+ export declare function syncOrgRepos(opts: {
20
+ teamId: string;
21
+ orgLogin: string;
22
+ userId: string;
23
+ verbose?: boolean;
24
+ }): Promise<SyncOrgReposResult>;
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Phase: sync-org-repos
3
+ *
4
+ * Fetches all repositories from a GitHub organization using the local `gh` CLI,
5
+ * then creates a product for each repo that isn't already linked to one
6
+ * within the same team.
7
+ *
8
+ * Uses `gh api --paginate` for truly unlimited pagination (no hardcoded cap).
9
+ * Forks and archived repos are filtered out client-side.
10
+ */
11
+ import { execFile } from 'child_process';
12
+ import { promisify } from 'util';
13
+ import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
14
+ import { logDebug, logError, logInfo, logWarning } from '../../utils/logger.js';
15
+ const execFileAsync = promisify(execFile);
16
+ const ORG_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
17
+ async function fetchOrgRepos(orgLogin, verbose) {
18
+ logInfo(`Fetching repos for org "${orgLogin}" via gh CLI...`);
19
+ const { stdout } = await execFileAsync('gh', [
20
+ 'api',
21
+ `--paginate`,
22
+ `/orgs/${encodeURIComponent(orgLogin)}/repos?per_page=100&type=sources&sort=updated`,
23
+ '--jq',
24
+ '.[] | select(.archived == false) | {name, full_name, description, html_url}',
25
+ ], { timeout: 120_000 });
26
+ if (!stdout.trim()) {
27
+ return [];
28
+ }
29
+ const repos = [];
30
+ for (const line of stdout.trim().split('\n')) {
31
+ if (!line)
32
+ continue;
33
+ try {
34
+ repos.push(JSON.parse(line));
35
+ }
36
+ catch {
37
+ // skip malformed lines
38
+ }
39
+ }
40
+ if (verbose) {
41
+ logDebug(`Fetched ${repos.length} source repos from ${orgLogin}`);
42
+ }
43
+ return repos;
44
+ }
45
+ export async function syncOrgRepos(opts) {
46
+ const { teamId, orgLogin, userId, verbose } = opts;
47
+ if (!ORG_NAME_RE.test(orgLogin) || orgLogin.length > 39) {
48
+ return {
49
+ status: 'error',
50
+ message: `Invalid GitHub organization name: "${orgLogin}"`,
51
+ total: 0,
52
+ created: 0,
53
+ skipped: 0,
54
+ };
55
+ }
56
+ if (!hasSupabaseSession()) {
57
+ return {
58
+ status: 'error',
59
+ message: 'Supabase session unavailable. Sign in to the Edsger desktop app.',
60
+ total: 0,
61
+ created: 0,
62
+ skipped: 0,
63
+ };
64
+ }
65
+ const supabase = getSupabase();
66
+ let orgRepos;
67
+ try {
68
+ orgRepos = await fetchOrgRepos(orgLogin, verbose);
69
+ }
70
+ catch (err) {
71
+ const msg = err instanceof Error ? err.message : String(err);
72
+ logError(`Failed to fetch repos: ${msg}`);
73
+ return {
74
+ status: 'error',
75
+ message: `Failed to fetch repos from GitHub CLI: ${msg}`,
76
+ total: 0,
77
+ created: 0,
78
+ skipped: 0,
79
+ };
80
+ }
81
+ if (orgRepos.length === 0) {
82
+ logWarning(`No repos found in org "${orgLogin}"`);
83
+ return {
84
+ status: 'success',
85
+ message: `No repositories found in ${orgLogin}`,
86
+ total: 0,
87
+ created: 0,
88
+ skipped: 0,
89
+ };
90
+ }
91
+ logInfo(`Found ${orgRepos.length} repos in ${orgLogin}`);
92
+ const repoFullNames = orgRepos.map((r) => r.full_name);
93
+ const { data: existing } = await supabase
94
+ .from('products')
95
+ .select('github_repository_full_name')
96
+ .eq('team_id', teamId)
97
+ .in('github_repository_full_name', repoFullNames);
98
+ const linkedRepos = new Set((existing || []).map((p) => p.github_repository_full_name));
99
+ const toCreate = orgRepos.filter((r) => !linkedRepos.has(r.full_name));
100
+ if (toCreate.length === 0) {
101
+ const msg = `All ${linkedRepos.size} repos already linked to products`;
102
+ logInfo(msg);
103
+ return {
104
+ status: 'success',
105
+ message: msg,
106
+ total: orgRepos.length,
107
+ created: 0,
108
+ skipped: linkedRepos.size,
109
+ };
110
+ }
111
+ if (verbose) {
112
+ for (const r of toCreate) {
113
+ logDebug(` Will create product: ${r.full_name}`);
114
+ }
115
+ }
116
+ const rows = toCreate.map((repo) => ({
117
+ name: repo.name,
118
+ description: repo.description,
119
+ team_id: teamId,
120
+ created_by: userId,
121
+ github_repository_full_name: repo.full_name,
122
+ github_repository_url: repo.html_url,
123
+ }));
124
+ const { error: insertError } = await supabase.from('products').insert(rows);
125
+ if (insertError) {
126
+ logError(`Failed to insert products: ${insertError.message}`);
127
+ return {
128
+ status: 'error',
129
+ message: `Failed to create products: ${insertError.message}`,
130
+ total: orgRepos.length,
131
+ created: 0,
132
+ skipped: linkedRepos.size,
133
+ };
134
+ }
135
+ return {
136
+ status: 'success',
137
+ message: `Created ${toCreate.length} products, ${linkedRepos.size} already existed`,
138
+ total: orgRepos.length,
139
+ created: toCreate.length,
140
+ skipped: linkedRepos.size,
141
+ repos: toCreate.map((r) => r.full_name),
142
+ };
143
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edsger",
3
- "version": "0.61.0",
3
+ "version": "0.62.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "edsger": "dist/index.js"