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 +6 -0
- package/package.json +1 -1
- package/schemas/portfolio-report.schema.json +7 -0
- package/src/cli.js +31 -4
- package/src/commands/analyze.js +28 -14
- package/src/commands/report.js +50 -0
- package/src/config.js +92 -5
- package/src/core/publicAliasGenerator.js +202 -0
- package/src/core/report.js +27 -9
- package/src/github/repos.js +47 -0
- package/src/utils/header.js +2 -2
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
|
@@ -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([
|
|
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 =
|
|
29
|
+
const strictMode = rawOptions.strict === true || rawOptions.strict === 'true';
|
|
21
30
|
|
|
22
31
|
if (strictMode) {
|
|
23
|
-
validateStrictOptions(command,
|
|
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
|
+
}
|
package/src/commands/analyze.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import {
|
|
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
|
-
|
|
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(
|
|
35
|
+
token = requireGithubToken(args);
|
|
23
36
|
} catch (err) {
|
|
24
|
-
fatal('GITHUB_TOKEN missing — set it in .env
|
|
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
|
|
30
|
-
const outputDir = typeof
|
|
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);
|
package/src/commands/report.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
20
|
+
export function requireGithubToken(args = {}) {
|
|
21
|
+
const env = getEnv(args);
|
|
22
|
+
|
|
13
23
|
if (!env.githubToken) {
|
|
14
|
-
throw new Error(
|
|
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
|
+
}
|
package/src/core/report.js
CHANGED
|
@@ -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
|
|
146
|
-
const inventorySignals = inventoryLookup.get(
|
|
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:
|
|
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
|
|
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((
|
|
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
|
|
package/src/github/repos.js
CHANGED
|
@@ -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,
|
package/src/utils/header.js
CHANGED
|
@@ -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;
|