github-portfolio-analyzer 1.1.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,18 @@
1
+ {
2
+ "name": "github-portfolio-analyzer",
3
+ "version": "1.0.0",
4
+ "commands": [
5
+ { "id": "analyze" },
6
+ { "id": "ingest-ideas" },
7
+ { "id": "build-portfolio" },
8
+ { "id": "report", "flags": ["--policy", "--explain", "--output", "--format", "--quiet"] }
9
+ ],
10
+ "outputs": [
11
+ "output/inventory.json",
12
+ "output/portfolio.json",
13
+ "output/portfolio-report.json"
14
+ ],
15
+ "schemas": {
16
+ "portfolioReport": "schemas/portfolio-report.schema.json"
17
+ }
18
+ }
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCli } from '../src/cli.js';
4
+
5
+ runCli(process.argv.slice(2)).catch((error) => {
6
+ console.error(`Error: ${error.message}`);
7
+ process.exitCode = Number.isInteger(error?.exitCode) ? error.exitCode : 1;
8
+ });
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "github-portfolio-analyzer",
3
+ "version": "1.1.0",
4
+ "description": "CLI tool to analyze GitHub repos and portfolio ideas",
5
+ "type": "module",
6
+ "bin": {
7
+ "github-portfolio-analyzer": "bin/github-portfolio-analyzer.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/github-portfolio-analyzer.js",
11
+ "analyze": "node bin/github-portfolio-analyzer.js analyze",
12
+ "ingest-ideas": "node bin/github-portfolio-analyzer.js ingest-ideas",
13
+ "build-portfolio": "node bin/github-portfolio-analyzer.js build-portfolio",
14
+ "report": "node bin/github-portfolio-analyzer.js report",
15
+ "lint": "eslint .",
16
+ "test": "node --test"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/paulo-raoni/github-portfolio-analyzer.git"
21
+ },
22
+ "files": [
23
+ "bin/",
24
+ "src/",
25
+ "schemas/",
26
+ "analyzer.manifest.json",
27
+ "README.md",
28
+ "CHANGELOG.md"
29
+ ],
30
+ "engines": {
31
+ "node": ">=22"
32
+ },
33
+ "dependencies": {
34
+ "dotenv": "^17.0.0"
35
+ },
36
+ "devDependencies": {
37
+ "eslint": "^10.0.2"
38
+ }
39
+ }
@@ -0,0 +1,266 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github-portfolio-analyzer.local/schemas/portfolio-report.schema.json",
4
+ "title": "GitHub Portfolio Analyzer Report",
5
+ "type": "object",
6
+ "additionalProperties": true,
7
+ "required": ["meta", "summary", "matrix", "items"],
8
+ "properties": {
9
+ "meta": {
10
+ "type": "object",
11
+ "additionalProperties": true,
12
+ "required": ["generatedAt", "asOfDate", "owner", "counts"],
13
+ "properties": {
14
+ "generatedAt": { "type": "string" },
15
+ "asOfDate": {
16
+ "type": ["string", "null"],
17
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
18
+ },
19
+ "owner": { "type": ["string", "null"] },
20
+ "counts": {
21
+ "type": "object",
22
+ "additionalProperties": true,
23
+ "required": ["total", "repos", "ideas"],
24
+ "properties": {
25
+ "total": { "type": "number" },
26
+ "repos": { "type": "number" },
27
+ "ideas": { "type": "number" }
28
+ }
29
+ }
30
+ },
31
+ "allOf": [
32
+ {
33
+ "if": {
34
+ "properties": { "asOfDate": { "type": "string" } }
35
+ },
36
+ "then": {
37
+ "properties": {
38
+ "asOfDate": {
39
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
40
+ }
41
+ }
42
+ }
43
+ }
44
+ ]
45
+ },
46
+ "summary": {
47
+ "type": "object",
48
+ "additionalProperties": true,
49
+ "required": ["byState", "top10ByScore", "now", "next", "later", "park"],
50
+ "properties": {
51
+ "byState": {
52
+ "type": "object",
53
+ "additionalProperties": true,
54
+ "required": ["active", "stale", "abandoned", "archived", "idea", "reference-only"],
55
+ "properties": {
56
+ "active": { "type": "number" },
57
+ "stale": { "type": "number" },
58
+ "abandoned": { "type": "number" },
59
+ "archived": { "type": "number" },
60
+ "idea": { "type": "number" },
61
+ "reference-only": { "type": "number" }
62
+ }
63
+ },
64
+ "top10ByScore": {
65
+ "type": "array",
66
+ "items": { "$ref": "#/$defs/summaryItem" }
67
+ },
68
+ "now": {
69
+ "type": "array",
70
+ "items": { "$ref": "#/$defs/summaryItem" }
71
+ },
72
+ "next": {
73
+ "type": "array",
74
+ "items": { "$ref": "#/$defs/summaryItem" }
75
+ },
76
+ "later": {
77
+ "type": "array",
78
+ "items": { "$ref": "#/$defs/summaryItem" }
79
+ },
80
+ "park": {
81
+ "type": "array",
82
+ "items": { "$ref": "#/$defs/summaryItem" }
83
+ }
84
+ }
85
+ },
86
+ "matrix": {
87
+ "type": "object",
88
+ "additionalProperties": true,
89
+ "required": ["completionByEffort"],
90
+ "properties": {
91
+ "completionByEffort": {
92
+ "type": "object",
93
+ "additionalProperties": true,
94
+ "required": ["CL0", "CL1", "CL2", "CL3", "CL4", "CL5"],
95
+ "properties": {
96
+ "CL0": { "$ref": "#/$defs/matrixRow" },
97
+ "CL1": { "$ref": "#/$defs/matrixRow" },
98
+ "CL2": { "$ref": "#/$defs/matrixRow" },
99
+ "CL3": { "$ref": "#/$defs/matrixRow" },
100
+ "CL4": { "$ref": "#/$defs/matrixRow" },
101
+ "CL5": { "$ref": "#/$defs/matrixRow" }
102
+ }
103
+ }
104
+ }
105
+ },
106
+ "items": {
107
+ "type": "array",
108
+ "items": { "$ref": "#/$defs/item" }
109
+ }
110
+ },
111
+ "$defs": {
112
+ "priorityOverride": {
113
+ "type": "object",
114
+ "additionalProperties": true,
115
+ "required": ["ruleId", "boost", "reason"],
116
+ "properties": {
117
+ "ruleId": { "type": "string" },
118
+ "boost": { "type": "number" },
119
+ "forceBand": {
120
+ "type": "string",
121
+ "enum": ["now", "next", "later", "park"]
122
+ },
123
+ "reason": { "type": "string" }
124
+ }
125
+ },
126
+ "summaryItem": {
127
+ "type": "object",
128
+ "additionalProperties": true,
129
+ "required": [
130
+ "slug",
131
+ "type",
132
+ "score",
133
+ "state",
134
+ "effort",
135
+ "effortEstimate",
136
+ "value",
137
+ "completionLevel",
138
+ "basePriorityScore",
139
+ "finalPriorityScore",
140
+ "priorityBand",
141
+ "priorityOverrides",
142
+ "priorityWhy",
143
+ "nextAction"
144
+ ],
145
+ "properties": {
146
+ "slug": { "type": "string" },
147
+ "type": {
148
+ "type": "string",
149
+ "enum": ["repo", "idea"]
150
+ },
151
+ "score": { "type": "number" },
152
+ "state": { "type": "string" },
153
+ "effort": { "type": "string" },
154
+ "effortEstimate": {
155
+ "type": "string",
156
+ "enum": ["xs", "s", "m", "l", "xl"]
157
+ },
158
+ "value": { "type": "string" },
159
+ "completionLevel": {
160
+ "type": "number",
161
+ "minimum": 0,
162
+ "maximum": 5
163
+ },
164
+ "basePriorityScore": { "type": "number" },
165
+ "finalPriorityScore": { "type": "number" },
166
+ "priorityBand": {
167
+ "type": "string",
168
+ "enum": ["now", "next", "later", "park"]
169
+ },
170
+ "priorityTag": { "type": "string" },
171
+ "priorityOverrides": {
172
+ "type": "array",
173
+ "items": { "$ref": "#/$defs/priorityOverride" }
174
+ },
175
+ "priorityWhy": {
176
+ "type": "array",
177
+ "items": { "type": "string" }
178
+ },
179
+ "nextAction": { "type": "string" }
180
+ }
181
+ },
182
+ "item": {
183
+ "type": "object",
184
+ "additionalProperties": true,
185
+ "required": [
186
+ "slug",
187
+ "type",
188
+ "title",
189
+ "score",
190
+ "state",
191
+ "effort",
192
+ "value",
193
+ "completionLevel",
194
+ "completionLabel",
195
+ "effortEstimate",
196
+ "basePriorityScore",
197
+ "finalPriorityScore",
198
+ "priorityBand",
199
+ "priorityOverrides",
200
+ "priorityWhy",
201
+ "nextAction"
202
+ ],
203
+ "properties": {
204
+ "slug": { "type": "string" },
205
+ "type": {
206
+ "type": "string",
207
+ "enum": ["repo", "idea"]
208
+ },
209
+ "title": { "type": "string" },
210
+ "score": { "type": "number" },
211
+ "state": { "type": "string" },
212
+ "effort": { "type": "string" },
213
+ "value": { "type": "string" },
214
+ "completionLevel": {
215
+ "type": "number",
216
+ "minimum": 0,
217
+ "maximum": 5
218
+ },
219
+ "completionLabel": { "type": "string" },
220
+ "effortEstimate": {
221
+ "type": "string",
222
+ "enum": ["xs", "s", "m", "l", "xl"]
223
+ },
224
+ "basePriorityScore": { "type": "number" },
225
+ "finalPriorityScore": { "type": "number" },
226
+ "priorityBand": {
227
+ "type": "string",
228
+ "enum": ["now", "next", "later", "park"]
229
+ },
230
+ "priorityTag": { "type": "string" },
231
+ "priorityOverrides": {
232
+ "type": "array",
233
+ "items": { "$ref": "#/$defs/priorityOverride" }
234
+ },
235
+ "priorityWhy": {
236
+ "type": "array",
237
+ "items": { "type": "string" }
238
+ },
239
+ "nextAction": { "type": "string" },
240
+ "language": { "type": "string" },
241
+ "topics": {
242
+ "type": "array",
243
+ "items": { "type": "string" }
244
+ },
245
+ "htmlUrl": { "type": "string" },
246
+ "homepage": { "type": "string" },
247
+ "presentationState": {
248
+ "type": "string",
249
+ "enum": ["featured", "complete", "in-progress", "salvageable", "learning", "archived", "hidden"]
250
+ }
251
+ }
252
+ },
253
+ "matrixRow": {
254
+ "type": "object",
255
+ "additionalProperties": true,
256
+ "required": ["xs", "s", "m", "l", "xl"],
257
+ "properties": {
258
+ "xs": { "type": "number" },
259
+ "s": { "type": "number" },
260
+ "m": { "type": "number" },
261
+ "l": { "type": "number" },
262
+ "xl": { "type": "number" }
263
+ }
264
+ }
265
+ }
266
+ }
package/src/cli.js ADDED
@@ -0,0 +1,103 @@
1
+ import { runAnalyzeCommand } from './commands/analyze.js';
2
+ import { runIngestIdeasCommand } from './commands/ingestIdeas.js';
3
+ import { runBuildPortfolioCommand } from './commands/buildPortfolio.js';
4
+ import { runReportCommand } from './commands/report.js';
5
+ import { parseArgs } from './utils/args.js';
6
+ import packageJson from '../package.json' with { type: 'json' };
7
+ import { UsageError } from './errors.js';
8
+
9
+ const GLOBAL_OPTIONS = new Set(['help', 'strict', 'version']);
10
+ const COMMAND_OPTIONS = {
11
+ analyze: new Set(['as-of', 'output-dir']),
12
+ 'ingest-ideas': new Set(['input', 'prompt', 'output-dir']),
13
+ 'build-portfolio': new Set(['output-dir']),
14
+ report: new Set(['output-dir', 'output', 'format', 'policy', 'priorities', 'explain', 'quiet'])
15
+ };
16
+
17
+ export async function runCli(argv) {
18
+ const { positional, options } = parseArgs(argv);
19
+ const [command] = positional;
20
+ const strictMode = options.strict === true || options.strict === 'true';
21
+
22
+ if (strictMode) {
23
+ validateStrictOptions(command, options);
24
+ }
25
+
26
+ if ((options.version === true && !command) || (command === '-v' && positional.length === 1)) {
27
+ console.log(packageJson.version);
28
+ return;
29
+ }
30
+
31
+ switch (command) {
32
+ case 'analyze':
33
+ await runAnalyzeCommand(options);
34
+ return;
35
+ case 'ingest-ideas':
36
+ await runIngestIdeasCommand(options);
37
+ return;
38
+ case 'build-portfolio':
39
+ await runBuildPortfolioCommand(options);
40
+ return;
41
+ case 'report':
42
+ await runReportCommand(options);
43
+ return;
44
+ case '-v':
45
+ console.log(packageJson.version);
46
+ return;
47
+ case '--help':
48
+ case '-h':
49
+ case undefined:
50
+ printHelp();
51
+ return;
52
+ default:
53
+ throw new UsageError(`Invalid command: ${command}`);
54
+ }
55
+ }
56
+
57
+ function printHelp() {
58
+ console.log('github-portfolio-analyzer');
59
+ console.log('Usage: github-portfolio-analyzer <command> [options]');
60
+ console.log(' --strict Global: fail on unknown flags (exit code 2)');
61
+ console.log('Commands:');
62
+ console.log(' analyze Analyze GitHub repositories and build inventory outputs');
63
+ console.log(' ingest-ideas Add or update manual project ideas');
64
+ console.log(' build-portfolio Merge repos and ideas into the portfolio outputs');
65
+ console.log(' report Generate decision-oriented portfolio reports');
66
+ console.log('');
67
+ console.log('Analyze options:');
68
+ console.log(' --as-of YYYY-MM-DD Snapshot date in UTC (defaults to today UTC)');
69
+ console.log(' --output-dir PATH Output directory (default: output)');
70
+ console.log('');
71
+ console.log('Ingest ideas options:');
72
+ console.log(' --input PATH Input JSON file (default: ideas/input.json)');
73
+ console.log(' --prompt Add ideas interactively');
74
+ console.log(' --output-dir PATH Output directory (default: output)');
75
+ console.log('');
76
+ console.log('Build portfolio options:');
77
+ console.log(' --output-dir PATH Output directory (default: output)');
78
+ console.log('');
79
+ console.log('Report options:');
80
+ console.log(' --output-dir PATH Output directory (default: output)');
81
+ console.log(' --format VALUE ascii|md|json|all (default: all)');
82
+ console.log(' --policy PATH Optional policy overlay JSON file');
83
+ console.log(' --priorities PATH Alias for --policy');
84
+ console.log(' --explain Print NOW ranking explainability to console');
85
+ console.log(' --quiet Suppress non-error logs');
86
+ }
87
+
88
+ function validateStrictOptions(command, options) {
89
+ const allowedOptions = new Set(GLOBAL_OPTIONS);
90
+ const commandOptions = COMMAND_OPTIONS[command];
91
+
92
+ if (commandOptions) {
93
+ for (const key of commandOptions) {
94
+ allowedOptions.add(key);
95
+ }
96
+ }
97
+
98
+ const unknown = Object.keys(options).filter((key) => !allowedOptions.has(key));
99
+ if (unknown.length > 0) {
100
+ const unknownFlags = unknown.map((key) => `--${key}`).join(', ');
101
+ throw new UsageError(`Unknown option(s): ${unknownFlags}`);
102
+ }
103
+ }
@@ -0,0 +1,172 @@
1
+ import path from 'node:path';
2
+ import { getEnv, requireGithubToken } from '../config.js';
3
+ import { GithubClient } from '../github/client.js';
4
+ import { fetchAllRepositories, normalizeRepository } from '../github/repos.js';
5
+ import { inspectRepositoryStructure } from '../github/repo-inspection.js';
6
+ import { classifyActivity, classifyMaturity } from '../core/classification.js';
7
+ import { scoreRepository } from '../core/scoring.js';
8
+ import { buildRepoTaxonomy } from '../core/taxonomy.js';
9
+ import { writeJsonFile } from '../io/files.js';
10
+ import { writeInventoryCsv } from '../io/csv.js';
11
+ import { mapWithConcurrency } from '../utils/concurrency.js';
12
+ import { resolveAsOfDate, utcNowISOString } from '../utils/time.js';
13
+ import { printHeader } from '../utils/header.js';
14
+ import { progress, success, error, warn, fatal } from '../utils/output.js';
15
+
16
+ export async function runAnalyzeCommand(options = {}) {
17
+ const startTime = Date.now();
18
+ const env = getEnv();
19
+
20
+ let token;
21
+ try {
22
+ token = requireGithubToken(env);
23
+ } catch (err) {
24
+ fatal('GITHUB_TOKEN missing — set it in .env: GITHUB_TOKEN=your_token');
25
+ throw err;
26
+ }
27
+
28
+ 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
+ });
39
+
40
+ let user;
41
+ try {
42
+ user = await github.getAuthenticatedUser();
43
+ } catch (err) {
44
+ if (err && (err.status === 401 || err.status === 403)) {
45
+ fatal('GitHub authentication failed — check your GITHUB_TOKEN permissions');
46
+ } else if (err && err.status === 429) {
47
+ fatal('GitHub API rate limit exceeded — wait or use a different token');
48
+ }
49
+ throw err;
50
+ }
51
+
52
+ let repositories;
53
+ try {
54
+ repositories = await fetchAllRepositories(github);
55
+ } catch (err) {
56
+ if (err && (err.status === 401 || err.status === 403)) {
57
+ fatal('GitHub authentication failed — check your GITHUB_TOKEN permissions');
58
+ } else if (err && err.status === 429) {
59
+ fatal('GitHub API rate limit exceeded — wait or use a different token');
60
+ }
61
+ throw err;
62
+ }
63
+
64
+ let analyzed = 0;
65
+ let fallbacks = 0;
66
+
67
+ const items = await mapWithConcurrency(repositories, 5, async (repo) => {
68
+ analyzed++;
69
+ const index = String(analyzed).padStart(String(repositories.length).length, ' ');
70
+ const normalized = normalizeRepository(repo);
71
+ try {
72
+ progress(`Analyzing ${index}/${repositories.length}: ${repo.name}`);
73
+ const structuralHealth = await inspectRepositoryStructure(github, normalized);
74
+ const activity = classifyActivity(normalized._pushedAt, asOfDate);
75
+ const maturity = classifyMaturity(normalized.sizeKb);
76
+ const { score, scoreBreakdown } = scoreRepository(
77
+ { ...normalized, structuralHealth, pushedAt: normalized._pushedAt, updatedAt: normalized._updatedAt },
78
+ asOfDate
79
+ );
80
+ const taxonomy = buildRepoTaxonomy({
81
+ ...normalized,
82
+ structuralHealth,
83
+ activity,
84
+ maturity,
85
+ score
86
+ });
87
+
88
+ return stripInternalFields({
89
+ ...normalized,
90
+ structuralHealth,
91
+ activity,
92
+ maturity,
93
+ score,
94
+ scoreBreakdown,
95
+ ...taxonomy
96
+ });
97
+ } catch (err) {
98
+ fallbacks++;
99
+ error(`✗ Analyzing ${index}/${repositories.length}: ${repo.name} — structural analysis failed, using fallback`);
100
+ const activity = classifyActivity(normalized._pushedAt, asOfDate);
101
+ const maturity = classifyMaturity(normalized.sizeKb);
102
+ const fallbackStructuralHealth = {
103
+ hasReadme: false,
104
+ hasLicense: Boolean(normalized.hasLicense),
105
+ hasPackageJson: false,
106
+ hasTests: false,
107
+ hasCi: false
108
+ };
109
+ const { score, scoreBreakdown } = scoreRepository(
110
+ {
111
+ ...normalized,
112
+ structuralHealth: fallbackStructuralHealth,
113
+ pushedAt: normalized._pushedAt,
114
+ updatedAt: normalized._updatedAt
115
+ },
116
+ asOfDate
117
+ );
118
+ const taxonomy = buildRepoTaxonomy({
119
+ ...normalized,
120
+ structuralHealth: fallbackStructuralHealth,
121
+ activity,
122
+ maturity,
123
+ score
124
+ });
125
+
126
+ return stripInternalFields({
127
+ ...normalized,
128
+ structuralHealth: fallbackStructuralHealth,
129
+ activity,
130
+ maturity,
131
+ score,
132
+ scoreBreakdown,
133
+ ...taxonomy,
134
+ analysisErrors: [`Structural analysis failed: ${err.message}`]
135
+ });
136
+ }
137
+ });
138
+
139
+ items.sort((left, right) => left.fullName.localeCompare(right.fullName));
140
+
141
+ const inventoryPath = path.join(outputDir, 'inventory.json');
142
+
143
+ await writeJsonFile(inventoryPath, {
144
+ meta: {
145
+ generatedAt: utcNowISOString(),
146
+ asOfDate,
147
+ owner: user.login,
148
+ count: items.length
149
+ },
150
+ items
151
+ });
152
+ const inventoryCsvPath = await writeInventoryCsv(outputDir, items);
153
+
154
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
155
+
156
+ if (fallbacks > 0) {
157
+ warn(`${fallbacks} repo(s) used fallback scoring — structural inspection failed`);
158
+ }
159
+ success(`✓ Analyzed ${repositories.length} repositories (${repositories.length - fallbacks} ok${fallbacks > 0 ? `, ${fallbacks} fallback` : ''}) in ${elapsed}s`);
160
+ success(`✓ Wrote ${inventoryPath}`);
161
+ success(`✓ Wrote ${inventoryCsvPath}`);
162
+ }
163
+
164
+ function stripInternalFields(item) {
165
+ const output = {};
166
+ for (const [key, value] of Object.entries(item)) {
167
+ if (!key.startsWith('_')) {
168
+ output[key] = value;
169
+ }
170
+ }
171
+ return output;
172
+ }
@@ -0,0 +1,8 @@
1
+ import { buildPortfolio } from '../core/portfolio.js';
2
+ import { info, success } from '../utils/output.js';
3
+
4
+ export async function runBuildPortfolioCommand(options = {}) {
5
+ info('Building portfolio...');
6
+ const result = await buildPortfolio(options);
7
+ success(`✓ Built portfolio — ${result.count} items → ${result.portfolioPath}`);
8
+ }
@@ -0,0 +1,8 @@
1
+ import { ingestIdeas } from '../core/ideas.js';
2
+ import { info, success } from '../utils/output.js';
3
+
4
+ export async function runIngestIdeasCommand(options = {}) {
5
+ info('Ingesting ideas...');
6
+ const result = await ingestIdeas(options);
7
+ success(`✓ Ingested ${result.count} ideas → ${result.outputPath}`);
8
+ }