github-portfolio-analyzer 1.2.0 → 1.3.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.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,13 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.3.0] — 2026-04-03
8
+
7
9
  ### Added
10
+ - `forkType` classification for forks via the GitHub compare API, distinguishing `active` forks from `passive` clones
11
+ - `publicAlias` best-effort generation for private repositories with OpenAI → Gemini → Anthropic fallback
12
+ - Global CLI credential flags: `--github-token`, `--github-username`, `--openai-key`, `--gemini-key`, `--anthropic-key`
13
+ - Interactive prompting for missing GitHub and optional LLM keys when `analyze` runs on a TTY
8
14
  - Colored terminal output — progress, success, warning, and error states with ANSI colors
9
15
  - Terminal header with ASCII art, version info, user, token status, and policy status
10
16
  - Per-repository progress logging during `analyze` (Analyzing N/total: repo-name)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-portfolio-analyzer",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "CLI tool to analyze GitHub repos and portfolio ideas",
5
5
  "type": "module",
6
6
  "bin": {
@@ -252,6 +252,13 @@
252
252
  "type": "string",
253
253
  "enum": ["product", "tooling", "library", "learning", "content", "infra", "experiment", "template"]
254
254
  },
255
+ "fork": { "type": "boolean" },
256
+ "forkType": {
257
+ "type": "string",
258
+ "enum": ["active", "passive"]
259
+ },
260
+ "private": { "type": "boolean" },
261
+ "publicAlias": { "type": "string" },
255
262
  "presentationState": {
256
263
  "type": "string",
257
264
  "enum": ["featured", "complete", "in-progress", "salvageable", "learning", "archived", "hidden"]
package/src/cli.js CHANGED
@@ -6,7 +6,16 @@ import { parseArgs } from './utils/args.js';
6
6
  import packageJson from '../package.json' with { type: 'json' };
7
7
  import { UsageError } from './errors.js';
8
8
 
9
- const GLOBAL_OPTIONS = new Set(['help', 'strict', 'version']);
9
+ const GLOBAL_OPTIONS = new Set([
10
+ 'help',
11
+ 'strict',
12
+ 'version',
13
+ 'github-token',
14
+ 'github-username',
15
+ 'openai-key',
16
+ 'gemini-key',
17
+ 'anthropic-key'
18
+ ]);
10
19
  const COMMAND_OPTIONS = {
11
20
  analyze: new Set(['as-of', 'output-dir']),
12
21
  'ingest-ideas': new Set(['input', 'prompt', 'output-dir']),
@@ -15,14 +24,16 @@ const COMMAND_OPTIONS = {
15
24
  };
16
25
 
17
26
  export async function runCli(argv) {
18
- const { positional, options } = parseArgs(argv);
27
+ const { positional, options: rawOptions } = parseArgs(argv);
19
28
  const [command] = positional;
20
- const strictMode = options.strict === true || options.strict === 'true';
29
+ const strictMode = rawOptions.strict === true || rawOptions.strict === 'true';
21
30
 
22
31
  if (strictMode) {
23
- validateStrictOptions(command, options);
32
+ validateStrictOptions(command, rawOptions);
24
33
  }
25
34
 
35
+ const options = mapCredentialOptions(rawOptions);
36
+
26
37
  if ((options.version === true && !command) || (command === '-v' && positional.length === 1)) {
27
38
  console.log(packageJson.version);
28
39
  return;
@@ -58,6 +69,11 @@ function printHelp() {
58
69
  console.log('github-portfolio-analyzer');
59
70
  console.log('Usage: github-portfolio-analyzer <command> [options]');
60
71
  console.log(' --strict Global: fail on unknown flags (exit code 2)');
72
+ console.log(' --github-token TOKEN Global: override GITHUB_TOKEN');
73
+ console.log(' --github-username USER Global: override GITHUB_USERNAME');
74
+ console.log(' --openai-key KEY Global: override OPENAI_API_KEY');
75
+ console.log(' --gemini-key KEY Global: override GEMINI_API_KEY');
76
+ console.log(' --anthropic-key KEY Global: override ANTHROPIC_API_KEY');
61
77
  console.log('Commands:');
62
78
  console.log(' analyze Analyze GitHub repositories and build inventory outputs');
63
79
  console.log(' ingest-ideas Add or update manual project ideas');
@@ -101,3 +117,14 @@ function validateStrictOptions(command, options) {
101
117
  throw new UsageError(`Unknown option(s): ${unknownFlags}`);
102
118
  }
103
119
  }
120
+
121
+ function mapCredentialOptions(options) {
122
+ return {
123
+ ...options,
124
+ ...(options['github-token'] !== undefined ? { githubToken: options['github-token'] } : {}),
125
+ ...(options['github-username'] !== undefined ? { githubUsername: options['github-username'] } : {}),
126
+ ...(options['openai-key'] !== undefined ? { openaiKey: options['openai-key'] } : {}),
127
+ ...(options['gemini-key'] !== undefined ? { geminiKey: options['gemini-key'] } : {}),
128
+ ...(options['anthropic-key'] !== undefined ? { anthropicKey: options['anthropic-key'] } : {})
129
+ };
130
+ }
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { getEnv, requireGithubToken } from '../config.js';
2
+ import { promptMissingKeys, requireGithubToken } from '../config.js';
3
3
  import { GithubClient } from '../github/client.js';
4
4
  import { fetchAllRepositories, normalizeRepository } from '../github/repos.js';
5
5
  import { inspectRepositoryStructure } from '../github/repo-inspection.js';
@@ -15,27 +15,32 @@ import { progress, success, error, warn, fatal } from '../utils/output.js';
15
15
 
16
16
  export async function runAnalyzeCommand(options = {}) {
17
17
  const startTime = Date.now();
18
- const env = getEnv();
18
+ let args = { ...options };
19
+
20
+ args = await promptMissingKeys(args, {
21
+ quiet: args.quiet,
22
+ required: [
23
+ { key: 'githubToken', label: 'GitHub Personal Access Token' }
24
+ ],
25
+ optional: [
26
+ { key: 'githubUsername', label: 'GitHub Username' },
27
+ { key: 'openaiKey', label: 'OpenAI API Key' },
28
+ { key: 'geminiKey', label: 'Gemini API Key' },
29
+ { key: 'anthropicKey', label: 'Anthropic API Key' }
30
+ ]
31
+ });
19
32
 
20
33
  let token;
21
34
  try {
22
- token = requireGithubToken(env);
35
+ token = requireGithubToken(args);
23
36
  } catch (err) {
24
- fatal('GITHUB_TOKEN missing — set it in .env: GITHUB_TOKEN=your_token');
37
+ fatal('GITHUB_TOKEN missing — set it in .env or pass --github-token');
25
38
  throw err;
26
39
  }
27
40
 
28
41
  const github = new GithubClient(token);
29
- const asOfDate = resolveAsOfDate(typeof options['as-of'] === 'string' ? options['as-of'] : undefined);
30
- const outputDir = typeof options['output-dir'] === 'string' ? options['output-dir'] : 'output';
31
-
32
- printHeader({
33
- command: 'analyze',
34
- asOfDate,
35
- outputDir,
36
- hasToken: Boolean(token),
37
- hasPolicy: false,
38
- });
42
+ const asOfDate = resolveAsOfDate(typeof args['as-of'] === 'string' ? args['as-of'] : undefined);
43
+ const outputDir = typeof args['output-dir'] === 'string' ? args['output-dir'] : 'output';
39
44
 
40
45
  let user;
41
46
  try {
@@ -49,6 +54,15 @@ export async function runAnalyzeCommand(options = {}) {
49
54
  throw err;
50
55
  }
51
56
 
57
+ printHeader({
58
+ command: 'analyze',
59
+ asOfDate,
60
+ outputDir,
61
+ hasToken: Boolean(token),
62
+ hasPolicy: false,
63
+ username: args.githubUsername || user.login
64
+ });
65
+
52
66
  let repositories;
53
67
  try {
54
68
  repositories = await fetchAllRepositories(github);
@@ -1,6 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import { buildReportModel } from '../core/report.js';
3
+ import { createPublicAliasLLMCaller, generatePublicAlias } from '../core/publicAliasGenerator.js';
3
4
  import { loadPresentationOverrides, applyPresentationOverrides } from '../core/presentationOverrides.js';
5
+ import { getEnv } from '../config.js';
4
6
  import { readJsonFile, readJsonFileIfExists } from '../io/files.js';
5
7
  import { writeReportAscii, writeReportJson, writeReportMarkdown } from '../io/report.js';
6
8
  import { UsageError } from '../errors.js';
@@ -46,11 +48,38 @@ export async function runReportCommand(options = {}) {
46
48
  const presentationOverridesPath = resolvePresentationOverridesPath(options);
47
49
  const presentationOverrides = await loadPresentationOverrides(presentationOverridesPath);
48
50
  const reportModel = buildReportModel(portfolio, inventory, { policyOverlay });
51
+ const portfolioDescriptionBySlug = new Map(
52
+ (Array.isArray(portfolio?.items) ? portfolio.items : []).map((item) => [
53
+ String(item?.slug ?? '').trim(),
54
+ item?.description ?? ''
55
+ ])
56
+ );
57
+ const callLLM = createPublicAliasLLMCaller(getEnv(options));
49
58
 
50
59
  if (presentationOverrides.size > 0) {
51
60
  reportModel.items = applyPresentationOverrides(reportModel.items, presentationOverrides);
52
61
  }
53
62
 
63
+ if (typeof callLLM === 'function') {
64
+ const privateItems = reportModel.items.filter((item) => item.private && !item.publicAlias);
65
+ const aliasBySlug = new Map();
66
+
67
+ for (const item of privateItems) {
68
+ const itemForAlias = {
69
+ ...item,
70
+ description: portfolioDescriptionBySlug.get(String(item.slug ?? '').trim()) ?? ''
71
+ };
72
+ item.publicAlias = await generatePublicAlias(itemForAlias, callLLM);
73
+ if (item.publicAlias) {
74
+ aliasBySlug.set(item.slug, item.publicAlias);
75
+ }
76
+ }
77
+
78
+ if (aliasBySlug.size > 0) {
79
+ applyAliasesToReportModel(reportModel, aliasBySlug);
80
+ }
81
+ }
82
+
54
83
  const writtenPaths = [];
55
84
 
56
85
  if (formatOption === 'json' || formatOption === 'all') {
@@ -127,6 +156,27 @@ function validatePolicyOverlay(policy, policyPath) {
127
156
  }
128
157
  }
129
158
 
159
+ function applyAliasesToReportModel(reportModel, aliasBySlug) {
160
+ for (const item of reportModel.items) {
161
+ if (!item.private || !item.publicAlias) {
162
+ continue;
163
+ }
164
+
165
+ item.slug = item.publicAlias;
166
+ item.title = item.publicAlias;
167
+ }
168
+
169
+ const sections = ['top10ByScore', 'now', 'next', 'later', 'park'];
170
+ for (const section of sections) {
171
+ for (const item of reportModel.summary[section] ?? []) {
172
+ const alias = aliasBySlug.get(item.slug);
173
+ if (alias) {
174
+ item.slug = alias;
175
+ }
176
+ }
177
+ }
178
+ }
179
+
130
180
  function printNowExplain(reportModel) {
131
181
  const nowItems = Array.isArray(reportModel?.items)
132
182
  ? reportModel.items.filter((item) => item.priorityBand === 'now')
package/src/config.js CHANGED
@@ -1,18 +1,105 @@
1
1
  import dotenv from 'dotenv';
2
+ import { createInterface } from 'node:readline';
2
3
 
3
4
  dotenv.config({ quiet: true });
4
5
 
5
- export function getEnv() {
6
+ /**
7
+ * Returns env vars with optional CLI overrides.
8
+ * args uses camelCase keys, for example { githubToken: '...' }.
9
+ */
10
+ export function getEnv(args = {}) {
6
11
  return {
7
- githubToken: process.env.GITHUB_TOKEN ?? '',
8
- githubUsername: process.env.GITHUB_USERNAME ?? ''
12
+ githubToken: args.githubToken ?? process.env.GITHUB_TOKEN ?? '',
13
+ githubUsername: args.githubUsername ?? process.env.GITHUB_USERNAME ?? '',
14
+ openaiKey: args.openaiKey ?? process.env.OPENAI_API_KEY ?? '',
15
+ geminiKey: args.geminiKey ?? process.env.GEMINI_API_KEY ?? '',
16
+ anthropicKey: args.anthropicKey ?? process.env.ANTHROPIC_API_KEY ?? ''
9
17
  };
10
18
  }
11
19
 
12
- export function requireGithubToken(env = getEnv()) {
20
+ export function requireGithubToken(args = {}) {
21
+ const env = getEnv(args);
22
+
13
23
  if (!env.githubToken) {
14
- throw new Error('Missing GITHUB_TOKEN. Add it to your .env file before running analyze.');
24
+ throw new Error(
25
+ 'Missing GITHUB_TOKEN. Add it to your .env file or pass --github-token <token>'
26
+ );
15
27
  }
16
28
 
17
29
  return env.githubToken;
18
30
  }
31
+
32
+ /**
33
+ * Interactive terminal prompt for missing keys.
34
+ * Only runs on TTY and when quiet !== true.
35
+ */
36
+ export async function promptMissingKeys(
37
+ args = {},
38
+ { required = [], optional = [], quiet = false, input = process.stdin, output = process.stderr } = {}
39
+ ) {
40
+ if (quiet || !input?.isTTY) {
41
+ return args;
42
+ }
43
+
44
+ const env = getEnv(args);
45
+ const result = { ...args };
46
+ const rl = createInterface({ input, output });
47
+ rl.stdoutMuted = false;
48
+
49
+ const askVisible = (label, hint) =>
50
+ new Promise((resolve) => {
51
+ rl.question(` ${label}${hint ? ` (${hint})` : ''}: `, resolve);
52
+ });
53
+
54
+ const askSilent = (label, hint) =>
55
+ new Promise((resolve) => {
56
+ const originalWriteToOutput = rl._writeToOutput;
57
+ const hintText = hint ? ` (${hint})` : '';
58
+ rl.output.write(` ${label}${hintText}: `);
59
+ rl.stdoutMuted = true;
60
+ rl._writeToOutput = (str) => {
61
+ if (rl.stdoutMuted) {
62
+ rl.output.write('');
63
+ return;
64
+ }
65
+
66
+ rl.output.write(str);
67
+ };
68
+
69
+ rl.question('', (value) => {
70
+ rl.stdoutMuted = false;
71
+ rl._writeToOutput = originalWriteToOutput;
72
+ rl.output.write('\n');
73
+ resolve(value);
74
+ });
75
+ });
76
+
77
+ for (const { key, label } of required) {
78
+ if (env[key]) {
79
+ continue;
80
+ }
81
+
82
+ const value = await askSilent(label, 'required');
83
+ if (!value.trim()) {
84
+ rl.close();
85
+ throw new Error(`${label} is required.`);
86
+ }
87
+
88
+ result[key] = value.trim();
89
+ }
90
+
91
+ for (const { key, label } of optional) {
92
+ if (env[key]) {
93
+ continue;
94
+ }
95
+
96
+ const prompt = key === 'githubUsername' ? askVisible : askSilent;
97
+ const value = await prompt(label, 'optional, Enter to skip');
98
+ if (value.trim()) {
99
+ result[key] = value.trim();
100
+ }
101
+ }
102
+
103
+ rl.close();
104
+ return result;
105
+ }
@@ -0,0 +1,202 @@
1
+ const OPENAI_URL = 'https://api.openai.com/v1/responses';
2
+ const GEMINI_URL =
3
+ 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
4
+ const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
5
+
6
+ /**
7
+ * Generates a plausible, non-identifying alias for private repositories.
8
+ * Preserves manually curated aliases when present.
9
+ */
10
+ export async function generatePublicAlias(item, callLLM) {
11
+ if (!item?.private) {
12
+ return null;
13
+ }
14
+
15
+ if (item.publicAlias) {
16
+ return item.publicAlias;
17
+ }
18
+
19
+ if (typeof callLLM !== 'function') {
20
+ return null;
21
+ }
22
+
23
+ const prompt = [
24
+ 'Given a private software project with:',
25
+ `- category: ${item.category ?? 'unknown'}`,
26
+ `- language: ${item.language ?? 'unknown'}`,
27
+ `- topics: ${Array.isArray(item.topics) ? item.topics.join(', ') : 'none'}`,
28
+ `- description: "${String(item.description ?? '').substring(0, 200)}"`,
29
+ '',
30
+ 'Generate a plausible but fictional project slug (2-3 words, kebab-case).',
31
+ 'Must reflect the technical domain. Must NOT contain: original repo name,',
32
+ 'company names, client names, person names, or any identifying information.',
33
+ 'Return ONLY the slug, nothing else. Example: "relay-task-engine"'
34
+ ].join('\n');
35
+
36
+ try {
37
+ const raw = await callLLM(prompt);
38
+ return (
39
+ raw
40
+ ?.trim()
41
+ .toLowerCase()
42
+ .replace(/[^a-z0-9-]/g, '-')
43
+ .replace(/-+/g, '-')
44
+ .replace(/^-|-$/g, '') || null
45
+ );
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ export function createPublicAliasLLMCaller(env = {}) {
52
+ const providers = [];
53
+
54
+ if (env.openaiKey) {
55
+ providers.push((prompt) => callOpenAI(env.openaiKey, prompt));
56
+ }
57
+
58
+ if (env.geminiKey) {
59
+ providers.push((prompt) => callGemini(env.geminiKey, prompt));
60
+ }
61
+
62
+ if (env.anthropicKey) {
63
+ providers.push((prompt) => callAnthropic(env.anthropicKey, prompt));
64
+ }
65
+
66
+ if (providers.length === 0) {
67
+ return null;
68
+ }
69
+
70
+ return async function callLLM(prompt) {
71
+ let lastError = null;
72
+
73
+ for (const provider of providers) {
74
+ try {
75
+ const value = await provider(prompt);
76
+ if (typeof value === 'string' && value.trim()) {
77
+ return value;
78
+ }
79
+ } catch (error) {
80
+ lastError = error;
81
+ }
82
+ }
83
+
84
+ if (lastError) {
85
+ throw lastError;
86
+ }
87
+
88
+ return null;
89
+ };
90
+ }
91
+
92
+ async function callOpenAI(apiKey, prompt) {
93
+ const data = await postJson(
94
+ OPENAI_URL,
95
+ {
96
+ model: 'gpt-4.1-mini',
97
+ input: prompt,
98
+ max_output_tokens: 40
99
+ },
100
+ {
101
+ Authorization: `Bearer ${apiKey}`
102
+ }
103
+ );
104
+
105
+ return extractOpenAIText(data);
106
+ }
107
+
108
+ async function callGemini(apiKey, prompt) {
109
+ const data = await postJson(
110
+ `${GEMINI_URL}?key=${encodeURIComponent(apiKey)}`,
111
+ {
112
+ contents: [
113
+ {
114
+ role: 'user',
115
+ parts: [{ text: prompt }]
116
+ }
117
+ ],
118
+ generationConfig: {
119
+ temperature: 0.2,
120
+ maxOutputTokens: 40
121
+ }
122
+ }
123
+ );
124
+
125
+ return data?.candidates?.[0]?.content?.parts
126
+ ?.map((part) => part?.text ?? '')
127
+ .join('')
128
+ .trim();
129
+ }
130
+
131
+ async function callAnthropic(apiKey, prompt) {
132
+ const data = await postJson(
133
+ ANTHROPIC_URL,
134
+ {
135
+ model: 'claude-3-5-haiku-latest',
136
+ max_tokens: 40,
137
+ messages: [
138
+ {
139
+ role: 'user',
140
+ content: prompt
141
+ }
142
+ ]
143
+ },
144
+ {
145
+ 'x-api-key': apiKey,
146
+ 'anthropic-version': '2023-06-01'
147
+ }
148
+ );
149
+
150
+ return data?.content
151
+ ?.map((part) => (part?.type === 'text' ? part.text : ''))
152
+ .join('')
153
+ .trim();
154
+ }
155
+
156
+ async function postJson(url, body, headers = {}) {
157
+ const response = await fetch(url, {
158
+ method: 'POST',
159
+ headers: {
160
+ 'content-type': 'application/json',
161
+ ...headers
162
+ },
163
+ body: JSON.stringify(body)
164
+ });
165
+
166
+ const data = await safeJson(response);
167
+ if (!response.ok) {
168
+ const details = data?.error?.message ?? data?.message ?? response.statusText;
169
+ throw new Error(`LLM request failed: ${response.status} ${details}`.trim());
170
+ }
171
+
172
+ return data;
173
+ }
174
+
175
+ async function safeJson(response) {
176
+ const contentType = response.headers.get('content-type') ?? '';
177
+ if (!contentType.includes('application/json')) {
178
+ return null;
179
+ }
180
+
181
+ try {
182
+ return await response.json();
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ function extractOpenAIText(data) {
189
+ if (typeof data?.output_text === 'string' && data.output_text.trim()) {
190
+ return data.output_text.trim();
191
+ }
192
+
193
+ for (const output of data?.output ?? []) {
194
+ for (const content of output?.content ?? []) {
195
+ if (typeof content?.text === 'string' && content.text.trim()) {
196
+ return content.text.trim();
197
+ }
198
+ }
199
+ }
200
+
201
+ return null;
202
+ }
@@ -142,8 +142,15 @@ export function buildReportModel(portfolioData, inventoryData = null, options =
142
142
  const inventoryLookup = buildInventoryLookup(inventoryItems);
143
143
 
144
144
  const reportItems = portfolioItems.map((item) => {
145
- const slug = String(item.slug ?? '').trim();
146
- const inventorySignals = inventoryLookup.get(slug) ?? null;
145
+ const rawSlug = String(item.slug ?? '').trim();
146
+ const inventorySignals = inventoryLookup.get(rawSlug) ?? null;
147
+ const isPrivate = Boolean(item.private);
148
+ const alias = typeof item.publicAlias === 'string' && item.publicAlias.trim()
149
+ ? item.publicAlias.trim()
150
+ : null;
151
+ const slug = isPrivate && alias ? alias : rawSlug;
152
+ const rawTitle = resolveTitle(item);
153
+ const title = isPrivate && alias ? alias : rawTitle;
147
154
 
148
155
  const completionLevel = computeCompletionLevel(item, inventorySignals);
149
156
  const effortEstimate = computeEffortEstimate(item, completionLevel, inventorySignals);
@@ -163,9 +170,9 @@ export function buildReportModel(portfolioData, inventoryData = null, options =
163
170
  } = applyPolicyOverlayToItem(
164
171
  {
165
172
  ...item,
166
- slug,
173
+ slug: rawSlug,
167
174
  type: resolveItemType(item),
168
- title: resolveTitle(item),
175
+ title: rawTitle,
169
176
  tags: collectItemTags(item)
170
177
  },
171
178
  {
@@ -180,7 +187,7 @@ export function buildReportModel(portfolioData, inventoryData = null, options =
180
187
  return {
181
188
  slug,
182
189
  type: resolveItemType(item),
183
- title: resolveTitle(item),
190
+ title,
184
191
  score: Number(item.score ?? 0),
185
192
  state: String(item.state ?? 'idea'),
186
193
  effort: normalizeEffort(item.effort) ?? 'm',
@@ -199,9 +206,15 @@ export function buildReportModel(portfolioData, inventoryData = null, options =
199
206
  // presentation fields — passed directly from portfolio item
200
207
  ...(item.language != null ? { language: item.language } : {}),
201
208
  ...(Array.isArray(item.topics) && item.topics.length > 0 ? { topics: item.topics } : {}),
202
- ...(item.htmlUrl != null ? { htmlUrl: item.htmlUrl } : {}),
203
- ...(item.homepage != null ? { homepage: item.homepage } : {}),
204
- ...(item.category != null ? { category: item.category } : {})
209
+ ...(!isPrivate && item.htmlUrl != null ? { htmlUrl: item.htmlUrl } : {}),
210
+ ...(!isPrivate && item.homepage != null ? { homepage: item.homepage } : {}),
211
+ ...(item.category != null ? { category: item.category } : {}),
212
+ ...(item.fork != null ? { fork: Boolean(item.fork) } : {}),
213
+ ...(item.forkType != null ? { forkType: item.forkType } : {}),
214
+ ...(item.private != null ? { private: Boolean(item.private) } : {}),
215
+ ...(item.publicAlias != null ? { publicAlias: item.publicAlias } : {}),
216
+ ...(!isPrivate && item.description != null ? { description: item.description } : {}),
217
+ ...(isPrivate && item.description != null ? { _description: item.description } : {})
205
218
  };
206
219
  });
207
220
 
@@ -260,7 +273,12 @@ export function buildReportModel(portfolioData, inventoryData = null, options =
260
273
  matrix: {
261
274
  completionByEffort: matrix
262
275
  },
263
- items: sortedByPriority.map(({ priorityScore: _priorityScore, ...item }) => item)
276
+ items: sortedByPriority.map((item) => {
277
+ const publicItem = { ...item };
278
+ delete publicItem.priorityScore;
279
+ delete publicItem._description;
280
+ return publicItem;
281
+ })
264
282
  };
265
283
  }
266
284
 
@@ -1,5 +1,39 @@
1
1
  const PAGE_SIZE = 100;
2
2
 
3
+ /**
4
+ * Classifies a fork as active or passive.
5
+ * Active forks have commits ahead of the upstream default branch.
6
+ */
7
+ export async function classifyFork(client, repo) {
8
+ if (!repo?.fork) {
9
+ return null;
10
+ }
11
+
12
+ const parent = repo.parent;
13
+ if (!parent) {
14
+ return 'passive';
15
+ }
16
+
17
+ const ownerLogin = repo.owner?.login ?? repo.ownerLogin;
18
+ const parentOwner = parent.owner?.login;
19
+ const parentBranch = parent.default_branch ?? parent.defaultBranch ?? 'main';
20
+ const branch = repo.default_branch ?? repo.defaultBranch ?? 'main';
21
+
22
+ if (!ownerLogin || !parentOwner || !repo.name) {
23
+ return 'passive';
24
+ }
25
+
26
+ try {
27
+ const comparison = await client.request(
28
+ `/repos/${encodeURIComponent(ownerLogin)}/${encodeURIComponent(repo.name)}/compare/${encodeURIComponent(parentOwner)}:${encodeURIComponent(parentBranch)}...${encodeURIComponent(ownerLogin)}:${encodeURIComponent(branch)}`
29
+ );
30
+
31
+ return (comparison?.ahead_by ?? 0) > 0 ? 'active' : 'passive';
32
+ } catch {
33
+ return 'passive';
34
+ }
35
+ }
36
+
3
37
  export async function fetchAllRepositories(client) {
4
38
  const repositories = [];
5
39
 
@@ -26,6 +60,17 @@ export async function fetchAllRepositories(client) {
26
60
  }
27
61
 
28
62
  repositories.sort((left, right) => left.full_name.localeCompare(right.full_name));
63
+
64
+ const forks = repositories.filter((repository) => repository.fork);
65
+ for (let index = 0; index < forks.length; index += 5) {
66
+ const batch = forks.slice(index, index + 5);
67
+ await Promise.all(
68
+ batch.map(async (repository) => {
69
+ repository.forkType = await classifyFork(client, repository);
70
+ })
71
+ );
72
+ }
73
+
29
74
  return repositories;
30
75
  }
31
76
 
@@ -39,6 +84,8 @@ export function normalizeRepository(repo) {
39
84
  private: repo.private,
40
85
  archived: repo.archived,
41
86
  fork: repo.fork,
87
+ forkType: repo.forkType ?? null,
88
+ parent: repo.parent ?? null,
42
89
  htmlUrl: repo.html_url,
43
90
  description: repo.description,
44
91
  language: repo.language,
@@ -13,9 +13,9 @@ ${AMBER} later█░░░ ↑${RESET}
13
13
  ${DIM} ↓${RESET}
14
14
  ${GREEN} ✓ report.json${RESET}`;
15
15
 
16
- export function printHeader({ command: _command, asOfDate, outputDir, hasToken, hasPolicy, version }) {
16
+ export function printHeader({ command: _command, asOfDate, outputDir, hasToken, hasPolicy, version, username }) {
17
17
  const node = process.version;
18
- const user = process.env.GITHUB_USERNAME ?? '—';
18
+ const user = username ?? process.env.GITHUB_USERNAME ?? '—';
19
19
  const token = hasToken ? `${GREEN}✓ set${RESET}` : `${AMBER}not set${RESET}`;
20
20
  const policy = hasPolicy ? `${GREEN}✓ set${RESET}` : `${GRAY}not set${RESET}`;
21
21
  const ver = version ?? packageJson.version;