github-portfolio-analyzer 1.2.0 → 1.4.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 +18 -0
- package/README.md +7 -6
- package/analyzer.manifest.json +12 -2
- package/package.json +1 -1
- package/schemas/portfolio-report.schema.json +9 -1
- package/src/cli.js +34 -5
- package/src/commands/analyze.js +24 -15
- package/src/commands/report.js +51 -1
- package/src/config.js +92 -5
- package/src/core/classification.js +1 -1
- package/src/core/publicAliasGenerator.js +202 -0
- package/src/core/report.js +31 -12
- package/src/core/taxonomy.js +14 -5
- package/src/github/client.js +2 -1
- package/src/github/repo-inspection.js +6 -2
- package/src/github/repos.js +55 -1
- package/src/io/markdown.js +1 -1
- package/src/utils/header.js +2 -2
- package/src/utils/time.js +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,25 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.4.0] — 2026-04-03
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Smoke tests for `--version` and `--help`
|
|
11
|
+
- Analyze command integration coverage for env-token execution and generated inventory fields
|
|
12
|
+
- `dormant` state in report summaries and documentation, while preserving manual `abandoned` compatibility
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- Automatic inactivity classification now returns `dormant` instead of `abandoned`
|
|
16
|
+
- Repository taxonomy inference now prioritizes `experiment` before `library`, broadens `learning` signals, and classifies simple apps like clocks/calculators/games as `product`
|
|
17
|
+
- Fork fallback classification now uses recent fork activity when upstream compare metadata is unavailable
|
|
18
|
+
|
|
19
|
+
## [1.3.0] — 2026-04-03
|
|
20
|
+
|
|
7
21
|
### Added
|
|
22
|
+
- `forkType` classification for forks via the GitHub compare API, distinguishing `active` forks from `passive` clones
|
|
23
|
+
- `publicAlias` best-effort generation for private repositories with OpenAI → Gemini → Anthropic fallback
|
|
24
|
+
- Global CLI credential flags: `--github-token`, `--github-username`, `--openai-key`, `--gemini-key`, `--anthropic-key`
|
|
25
|
+
- Interactive prompting for missing GitHub and optional LLM keys when `analyze` runs on a TTY
|
|
8
26
|
- Colored terminal output — progress, success, warning, and error states with ANSI colors
|
|
9
27
|
- Terminal header with ASCII art, version info, user, token status, and policy status
|
|
10
28
|
- Per-repository progress logging during `analyze` (Analyzing N/total: repo-name)
|
package/README.md
CHANGED
|
@@ -495,7 +495,8 @@ Each `portfolio.json.items[]` entry includes:
|
|
|
495
495
|
|
|
496
496
|
- `type`: `repo | idea`
|
|
497
497
|
- `category`: `product | tooling | library | learning | content | infra | experiment | template`
|
|
498
|
-
- `state`: `idea | active | stale | abandoned | archived | reference-only`
|
|
498
|
+
- `state`: `idea | active | stale | dormant | abandoned | archived | reference-only`
|
|
499
|
+
- Auto-classified repository inactivity uses `dormant`; `abandoned` remains supported for manual curation.
|
|
499
500
|
- `strategy`: `strategic-core | strategic-support | opportunistic | maintenance | parked`
|
|
500
501
|
- `effort`: `xs | s | m | l | xl`
|
|
501
502
|
- `value`: `low | medium | high | very-high`
|
|
@@ -521,11 +522,11 @@ Every repository passes through a deterministic scoring pipeline:
|
|
|
521
522
|
flowchart LR
|
|
522
523
|
subgraph top [ ]
|
|
523
524
|
direction LR
|
|
524
|
-
A([repo metadata]) --> B(inferRepoCategory) --> C([category]) --> D(scoreRepository) --> E([score 0
|
|
525
|
+
A([repo metadata]) --> B(inferRepoCategory) --> C([category]) --> D(scoreRepository) --> E([score 0-100])
|
|
525
526
|
end
|
|
526
527
|
subgraph mid [ ]
|
|
527
528
|
direction RL
|
|
528
|
-
J(computePriorityBand) <-- I([effort xs
|
|
529
|
+
J(computePriorityBand) <-- I([effort xs-xl]) <-- H(computeEffortEstimate) <-- G([CL 0-5]) <-- F(computeCompletionLevel)
|
|
529
530
|
end
|
|
530
531
|
E --> F
|
|
531
532
|
E -. feeds .-> J
|
|
@@ -606,7 +607,7 @@ final `priorityScore`, which determines the band.
|
|
|
606
607
|
|---|---|---|
|
|
607
608
|
| State boost | `active` | +10 |
|
|
608
609
|
| State boost | `stale` | +5 |
|
|
609
|
-
| State penalty | `abandoned
|
|
610
|
+
| State penalty | `dormant`, `abandoned`, or `archived` | −20 |
|
|
610
611
|
| Quick-win boost | CL 1, 2, or 3 | +10 |
|
|
611
612
|
| Effort penalty | `l` or `xl` | −10 |
|
|
612
613
|
|
|
@@ -614,7 +615,7 @@ final `priorityScore`, which determines the band.
|
|
|
614
615
|
|
|
615
616
|
| Band | Range | Meaning |
|
|
616
617
|
|---|---|---|
|
|
617
|
-
| `park` | < 45 | Needs a decision before any investment.
|
|
618
|
+
| `park` | < 45 | Needs a decision before any investment. Dormant, low signal, or intentionally paused. |
|
|
618
619
|
| `later` | 45–64 | Viable but not urgent. Can return when backlog has room. |
|
|
619
620
|
| `next` | 65–79 | Strong candidate. High score but large effort, or active with average score. |
|
|
620
621
|
| `now` | ≥ 80 | High confidence. Active project, good score, low effort — or manually pinned. |
|
|
@@ -633,7 +634,7 @@ updated 200d ago +0 (> 180 days)
|
|
|
633
634
|
──────────────────────────────────
|
|
634
635
|
score 25
|
|
635
636
|
|
|
636
|
-
state=
|
|
637
|
+
state=dormant −20
|
|
637
638
|
effort=xl −10
|
|
638
639
|
──────────────────────────────────
|
|
639
640
|
priorityScore −5 → park
|
package/analyzer.manifest.json
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "github-portfolio-analyzer",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"commands": [
|
|
5
5
|
{ "id": "analyze" },
|
|
6
6
|
{ "id": "ingest-ideas" },
|
|
7
7
|
{ "id": "build-portfolio" },
|
|
8
|
-
{
|
|
8
|
+
{
|
|
9
|
+
"id": "report",
|
|
10
|
+
"flags": [
|
|
11
|
+
"--policy",
|
|
12
|
+
"--explain",
|
|
13
|
+
"--output",
|
|
14
|
+
"--format",
|
|
15
|
+
"--quiet",
|
|
16
|
+
"--presentation-overrides"
|
|
17
|
+
]
|
|
18
|
+
}
|
|
9
19
|
],
|
|
10
20
|
"outputs": [
|
|
11
21
|
"output/inventory.json",
|
package/package.json
CHANGED
|
@@ -51,10 +51,11 @@
|
|
|
51
51
|
"byState": {
|
|
52
52
|
"type": "object",
|
|
53
53
|
"additionalProperties": true,
|
|
54
|
-
"required": ["active", "stale", "abandoned", "archived", "idea", "reference-only"],
|
|
54
|
+
"required": ["active", "stale", "dormant", "abandoned", "archived", "idea", "reference-only"],
|
|
55
55
|
"properties": {
|
|
56
56
|
"active": { "type": "number" },
|
|
57
57
|
"stale": { "type": "number" },
|
|
58
|
+
"dormant": { "type": "number" },
|
|
58
59
|
"abandoned": { "type": "number" },
|
|
59
60
|
"archived": { "type": "number" },
|
|
60
61
|
"idea": { "type": "number" },
|
|
@@ -252,6 +253,13 @@
|
|
|
252
253
|
"type": "string",
|
|
253
254
|
"enum": ["product", "tooling", "library", "learning", "content", "infra", "experiment", "template"]
|
|
254
255
|
},
|
|
256
|
+
"fork": { "type": "boolean" },
|
|
257
|
+
"forkType": {
|
|
258
|
+
"type": "string",
|
|
259
|
+
"enum": ["active", "passive"]
|
|
260
|
+
},
|
|
261
|
+
"private": { "type": "boolean" },
|
|
262
|
+
"publicAlias": { "type": "string" },
|
|
255
263
|
"presentationState": {
|
|
256
264
|
"type": "string",
|
|
257
265
|
"enum": ["featured", "complete", "in-progress", "salvageable", "learning", "archived", "hidden"]
|
package/src/cli.js
CHANGED
|
@@ -6,23 +6,34 @@ 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']),
|
|
13
22
|
'build-portfolio': new Set(['output-dir']),
|
|
14
|
-
report: new Set(['output-dir', 'output', 'format', 'policy', 'priorities', 'explain', 'quiet'])
|
|
23
|
+
report: new Set(['output-dir', 'output', 'format', 'policy', 'priorities', 'explain', 'quiet', 'presentation-overrides'])
|
|
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');
|
|
@@ -81,6 +97,8 @@ function printHelp() {
|
|
|
81
97
|
console.log(' --format VALUE ascii|md|json|all (default: all)');
|
|
82
98
|
console.log(' --policy PATH Optional policy overlay JSON file');
|
|
83
99
|
console.log(' --priorities PATH Alias for --policy');
|
|
100
|
+
console.log(' --presentation-overrides PATH');
|
|
101
|
+
console.log(' Optional presentation overrides JSON file');
|
|
84
102
|
console.log(' --explain Print NOW ranking explainability to console');
|
|
85
103
|
console.log(' --quiet Suppress non-error logs');
|
|
86
104
|
}
|
|
@@ -101,3 +119,14 @@ function validateStrictOptions(command, options) {
|
|
|
101
119
|
throw new UsageError(`Unknown option(s): ${unknownFlags}`);
|
|
102
120
|
}
|
|
103
121
|
}
|
|
122
|
+
|
|
123
|
+
function mapCredentialOptions(options) {
|
|
124
|
+
return {
|
|
125
|
+
...options,
|
|
126
|
+
...(options['github-token'] !== undefined ? { githubToken: options['github-token'] } : {}),
|
|
127
|
+
...(options['github-username'] !== undefined ? { githubUsername: options['github-username'] } : {}),
|
|
128
|
+
...(options['openai-key'] !== undefined ? { openaiKey: options['openai-key'] } : {}),
|
|
129
|
+
...(options['gemini-key'] !== undefined ? { geminiKey: options['gemini-key'] } : {}),
|
|
130
|
+
...(options['anthropic-key'] !== undefined ? { anthropicKey: options['anthropic-key'] } : {})
|
|
131
|
+
};
|
|
132
|
+
}
|
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,27 @@ 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
|
+
});
|
|
19
27
|
|
|
20
28
|
let token;
|
|
21
29
|
try {
|
|
22
|
-
token = requireGithubToken(
|
|
30
|
+
token = requireGithubToken(args);
|
|
23
31
|
} catch (err) {
|
|
24
|
-
fatal('GITHUB_TOKEN missing — set it in .env
|
|
32
|
+
fatal('GITHUB_TOKEN missing — set it in .env or pass --github-token');
|
|
25
33
|
throw err;
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
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
|
-
});
|
|
37
|
+
const asOfDate = resolveAsOfDate(typeof args['as-of'] === 'string' ? args['as-of'] : undefined);
|
|
38
|
+
const outputDir = typeof args['output-dir'] === 'string' ? args['output-dir'] : 'output';
|
|
39
39
|
|
|
40
40
|
let user;
|
|
41
41
|
try {
|
|
@@ -49,9 +49,18 @@ export async function runAnalyzeCommand(options = {}) {
|
|
|
49
49
|
throw err;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
printHeader({
|
|
53
|
+
command: 'analyze',
|
|
54
|
+
asOfDate,
|
|
55
|
+
outputDir,
|
|
56
|
+
hasToken: Boolean(token),
|
|
57
|
+
hasPolicy: false,
|
|
58
|
+
username: args.githubUsername || user.login
|
|
59
|
+
});
|
|
60
|
+
|
|
52
61
|
let repositories;
|
|
53
62
|
try {
|
|
54
|
-
repositories = await fetchAllRepositories(github);
|
|
63
|
+
repositories = await fetchAllRepositories(github, asOfDate);
|
|
55
64
|
} catch (err) {
|
|
56
65
|
if (err && (err.status === 401 || err.status === 403)) {
|
|
57
66
|
fatal('GitHub authentication failed — check your GITHUB_TOKEN permissions');
|
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')
|
|
@@ -185,7 +235,7 @@ function computeStateAdjustment(state) {
|
|
|
185
235
|
return 5;
|
|
186
236
|
}
|
|
187
237
|
|
|
188
|
-
if (state === 'abandoned' || state === 'archived') {
|
|
238
|
+
if (state === 'dormant' || state === 'abandoned' || state === 'archived') {
|
|
189
239
|
return -20;
|
|
190
240
|
}
|
|
191
241
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { utcNowISOString } from '../utils/time.js';
|
|
2
2
|
|
|
3
|
-
const STATE_ORDER = ['active', 'stale', 'abandoned', 'archived', 'idea', 'reference-only'];
|
|
3
|
+
const STATE_ORDER = ['active', 'stale', 'dormant', 'abandoned', 'archived', 'idea', 'reference-only'];
|
|
4
4
|
const EFFORT_ORDER = ['xs', 's', 'm', 'l', 'xl'];
|
|
5
5
|
const BAND_STRENGTH = {
|
|
6
6
|
park: 0,
|
|
@@ -109,9 +109,9 @@ export function computePriorityBand(input) {
|
|
|
109
109
|
} else if (state === 'stale') {
|
|
110
110
|
priorityScore += 5;
|
|
111
111
|
reasons.push('State boost: stale (+5)');
|
|
112
|
-
} else if (state === 'abandoned' || state === 'archived') {
|
|
112
|
+
} else if (state === 'dormant' || state === 'abandoned' || state === 'archived') {
|
|
113
113
|
priorityScore -= 20;
|
|
114
|
-
reasons.push('State penalty: abandoned/archived (-20)');
|
|
114
|
+
reasons.push('State penalty: dormant/abandoned/archived (-20)');
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
if (completionLevel >= 1 && completionLevel <= 3) {
|
|
@@ -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
|
|
|
@@ -291,6 +309,7 @@ function buildByStateSummary(reportItems) {
|
|
|
291
309
|
const summary = {
|
|
292
310
|
active: 0,
|
|
293
311
|
stale: 0,
|
|
312
|
+
dormant: 0,
|
|
294
313
|
abandoned: 0,
|
|
295
314
|
archived: 0,
|
|
296
315
|
idea: 0,
|
package/src/core/taxonomy.js
CHANGED
|
@@ -6,13 +6,14 @@ function inferRepoCategory(repository) {
|
|
|
6
6
|
const topics = Array.isArray(repository.topics)
|
|
7
7
|
? repository.topics.map((topic) => String(topic).toLowerCase())
|
|
8
8
|
: [];
|
|
9
|
+
const nameAndTopics = [name, ...topics].join(' ');
|
|
9
10
|
const all = [name, desc, ...topics].join(' ');
|
|
10
11
|
|
|
11
12
|
if (/\b(prompt|note|notes|snippet|snippets|cheatsheet|doc|docs|documentation|knowledge|wiki|resource|resources|writing|content|guide|guides|cookbook)\b/.test(all)) {
|
|
12
13
|
return 'content';
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
if (/\b(learn|learning|study|exercise|exercises|course|tutorial|tutorials|practice|training|bootcamp|challenge|challenges|kata)\b/.test(all)) {
|
|
16
|
+
if (/\b(learn|learning|study|exercise|exercises|course|tutorial|tutorials|practice|training|bootcamp|challenge|challenges|kata|curriculum|syllabus)\b/.test(all)) {
|
|
16
17
|
return 'learning';
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -20,6 +21,10 @@ function inferRepoCategory(repository) {
|
|
|
20
21
|
return 'template';
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
if (/\b(poc|proof|experiment|spike|prototype|sandbox|playground)\b/.test(nameAndTopics)) {
|
|
25
|
+
return 'experiment';
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
if (/\b(lib|library|sdk|package|npm|module|plugin|extension|addon|util|utils|helper|helpers)\b/.test(all)) {
|
|
24
29
|
return 'library';
|
|
25
30
|
}
|
|
@@ -28,11 +33,11 @@ function inferRepoCategory(repository) {
|
|
|
28
33
|
return 'infra';
|
|
29
34
|
}
|
|
30
35
|
|
|
31
|
-
if (/\b(
|
|
36
|
+
if (/\b(demo|try|trying)\b/.test(all)) {
|
|
32
37
|
return 'experiment';
|
|
33
38
|
}
|
|
34
39
|
|
|
35
|
-
if (/\b(app|application|system|platform|service|api|backend|frontend|web|mobile|dashboard|portal|saas)\b/.test(all)) {
|
|
40
|
+
if (/\b(app|application|system|platform|service|api|backend|frontend|web|mobile|dashboard|portal|saas|clock|calculator|game|games|viewer|weather|timer|todo|player|tracker)\b/.test(all)) {
|
|
36
41
|
return 'product';
|
|
37
42
|
}
|
|
38
43
|
|
|
@@ -130,6 +135,10 @@ export function mapIdeaStatusToState(status) {
|
|
|
130
135
|
return 'stale';
|
|
131
136
|
}
|
|
132
137
|
|
|
138
|
+
if (value === 'dormant') {
|
|
139
|
+
return 'dormant';
|
|
140
|
+
}
|
|
141
|
+
|
|
133
142
|
if (value === 'abandoned' || value === 'dropped') {
|
|
134
143
|
return 'abandoned';
|
|
135
144
|
}
|
|
@@ -154,7 +163,7 @@ function defaultRepoNextAction(state) {
|
|
|
154
163
|
return formatNextAction('Refresh', 'execution documentation', 'README run steps are validated in a clean environment');
|
|
155
164
|
}
|
|
156
165
|
|
|
157
|
-
if (state === 'abandoned') {
|
|
166
|
+
if (state === 'dormant' || state === 'abandoned') {
|
|
158
167
|
return formatNextAction('Decide', 'retain or archive status', 'README contains a documented decision and rationale');
|
|
159
168
|
}
|
|
160
169
|
|
|
@@ -170,7 +179,7 @@ function normalizeCategory(value) {
|
|
|
170
179
|
}
|
|
171
180
|
|
|
172
181
|
function normalizeState(value, fallback) {
|
|
173
|
-
return normalizeEnum(value, ['idea', 'active', 'stale', 'abandoned', 'archived', 'reference-only']) ?? fallback;
|
|
182
|
+
return normalizeEnum(value, ['idea', 'active', 'stale', 'dormant', 'abandoned', 'archived', 'reference-only']) ?? fallback;
|
|
174
183
|
}
|
|
175
184
|
|
|
176
185
|
function normalizeStrategy(value) {
|
package/src/github/client.js
CHANGED
|
@@ -15,6 +15,7 @@ export class GithubClient {
|
|
|
15
15
|
constructor(token) {
|
|
16
16
|
this.token = token;
|
|
17
17
|
this.maxRetries = 4;
|
|
18
|
+
this._delay = sleepMs;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
async request(path, init = {}) {
|
|
@@ -42,7 +43,7 @@ export class GithubClient {
|
|
|
42
43
|
responseHeaders: response.headers,
|
|
43
44
|
attempt
|
|
44
45
|
});
|
|
45
|
-
await
|
|
46
|
+
await this._delay(delayMs);
|
|
46
47
|
continue;
|
|
47
48
|
}
|
|
48
49
|
|
|
@@ -37,8 +37,12 @@ async function readPackageJson(client, repository) {
|
|
|
37
37
|
try {
|
|
38
38
|
const response = await client.request(repoApiPath(repository, 'contents/package.json'));
|
|
39
39
|
if (response?.content && response?.encoding === 'base64') {
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
try {
|
|
41
|
+
const decoded = Buffer.from(response.content, 'base64').toString('utf8');
|
|
42
|
+
return JSON.parse(decoded);
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
42
46
|
}
|
|
43
47
|
return null;
|
|
44
48
|
} catch (error) {
|
package/src/github/repos.js
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
|
+
import { daysSince } from '../core/classification.js';
|
|
2
|
+
|
|
1
3
|
const PAGE_SIZE = 100;
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Classifies a fork as active or passive.
|
|
7
|
+
* Active forks are either ahead of the upstream default branch or recently active
|
|
8
|
+
* when upstream comparison metadata is unavailable.
|
|
9
|
+
*/
|
|
10
|
+
export async function classifyFork(client, repo, asOfDate = new Date().toISOString().slice(0, 10)) {
|
|
11
|
+
if (!repo?.fork) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const parent = repo.parent;
|
|
16
|
+
const pushedAt = repo._pushedAt ?? repo.pushed_at ?? repo.pushedAt ?? null;
|
|
17
|
+
const isRecentlyActive = pushedAt ? daysSince(pushedAt, asOfDate) <= 90 : false;
|
|
18
|
+
const fallbackForkType = isRecentlyActive ? 'active' : 'passive';
|
|
19
|
+
|
|
20
|
+
if (!parent) {
|
|
21
|
+
return fallbackForkType;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const ownerLogin = repo.owner?.login ?? repo.ownerLogin;
|
|
25
|
+
const parentOwner = parent.owner?.login;
|
|
26
|
+
const parentBranch = parent.default_branch ?? parent.defaultBranch ?? 'main';
|
|
27
|
+
const branch = repo.default_branch ?? repo.defaultBranch ?? 'main';
|
|
28
|
+
|
|
29
|
+
if (!ownerLogin || !parentOwner || !repo.name) {
|
|
30
|
+
return fallbackForkType;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const comparison = await client.request(
|
|
35
|
+
`/repos/${encodeURIComponent(ownerLogin)}/${encodeURIComponent(repo.name)}/compare/${encodeURIComponent(parentOwner)}:${encodeURIComponent(parentBranch)}...${encodeURIComponent(ownerLogin)}:${encodeURIComponent(branch)}`
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
return (comparison?.ahead_by ?? 0) > 0 ? 'active' : 'passive';
|
|
39
|
+
} catch {
|
|
40
|
+
return fallbackForkType;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function fetchAllRepositories(client, asOfDate = new Date().toISOString().slice(0, 10)) {
|
|
4
45
|
const repositories = [];
|
|
5
46
|
|
|
6
47
|
for (let page = 1; ; page += 1) {
|
|
@@ -26,6 +67,17 @@ export async function fetchAllRepositories(client) {
|
|
|
26
67
|
}
|
|
27
68
|
|
|
28
69
|
repositories.sort((left, right) => left.full_name.localeCompare(right.full_name));
|
|
70
|
+
|
|
71
|
+
const forks = repositories.filter((repository) => repository.fork);
|
|
72
|
+
for (let index = 0; index < forks.length; index += 5) {
|
|
73
|
+
const batch = forks.slice(index, index + 5);
|
|
74
|
+
await Promise.all(
|
|
75
|
+
batch.map(async (repository) => {
|
|
76
|
+
repository.forkType = await classifyFork(client, repository, asOfDate);
|
|
77
|
+
})
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
29
81
|
return repositories;
|
|
30
82
|
}
|
|
31
83
|
|
|
@@ -39,6 +91,8 @@ export function normalizeRepository(repo) {
|
|
|
39
91
|
private: repo.private,
|
|
40
92
|
archived: repo.archived,
|
|
41
93
|
fork: repo.fork,
|
|
94
|
+
forkType: repo.forkType ?? null,
|
|
95
|
+
parent: repo.parent ?? null,
|
|
42
96
|
htmlUrl: repo.html_url,
|
|
43
97
|
description: repo.description,
|
|
44
98
|
language: repo.language,
|
package/src/io/markdown.js
CHANGED
|
@@ -109,7 +109,7 @@ function renderPortfolioSummary(payload) {
|
|
|
109
109
|
|
|
110
110
|
lines.push('');
|
|
111
111
|
|
|
112
|
-
for (const state of ['active', 'stale', 'abandoned', 'idea']) {
|
|
112
|
+
for (const state of ['active', 'stale', 'dormant', 'abandoned', 'idea']) {
|
|
113
113
|
lines.push(`## State: ${state}`);
|
|
114
114
|
lines.push('');
|
|
115
115
|
|
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;
|
package/src/utils/time.js
CHANGED
|
@@ -14,6 +14,9 @@ export function resolveAsOfDate(input) {
|
|
|
14
14
|
if (Number.isNaN(asDate.getTime())) {
|
|
15
15
|
throw new Error(`Invalid --as-of value: ${input}. Expected YYYY-MM-DD.`);
|
|
16
16
|
}
|
|
17
|
+
if (asDate.toISOString().slice(0, 10) !== input) {
|
|
18
|
+
throw new Error(`Invalid --as-of value: ${input}. Date does not exist in the calendar.`);
|
|
19
|
+
}
|
|
17
20
|
|
|
18
21
|
return input;
|
|
19
22
|
}
|