github-portfolio-analyzer 1.3.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 +12 -0
- package/README.md +7 -6
- package/analyzer.manifest.json +12 -2
- package/package.json +1 -1
- package/schemas/portfolio-report.schema.json +2 -1
- package/src/cli.js +3 -1
- package/src/commands/analyze.js +2 -7
- package/src/commands/report.js +1 -1
- package/src/core/classification.js +1 -1
- package/src/core/report.js +4 -3
- 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 +14 -7
- package/src/io/markdown.js +1 -1
- package/src/utils/time.js +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ 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
|
+
|
|
7
19
|
## [1.3.0] — 2026-04-03
|
|
8
20
|
|
|
9
21
|
### Added
|
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" },
|
package/src/cli.js
CHANGED
|
@@ -20,7 +20,7 @@ const COMMAND_OPTIONS = {
|
|
|
20
20
|
analyze: new Set(['as-of', 'output-dir']),
|
|
21
21
|
'ingest-ideas': new Set(['input', 'prompt', 'output-dir']),
|
|
22
22
|
'build-portfolio': new Set(['output-dir']),
|
|
23
|
-
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'])
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
export async function runCli(argv) {
|
|
@@ -97,6 +97,8 @@ function printHelp() {
|
|
|
97
97
|
console.log(' --format VALUE ascii|md|json|all (default: all)');
|
|
98
98
|
console.log(' --policy PATH Optional policy overlay JSON file');
|
|
99
99
|
console.log(' --priorities PATH Alias for --policy');
|
|
100
|
+
console.log(' --presentation-overrides PATH');
|
|
101
|
+
console.log(' Optional presentation overrides JSON file');
|
|
100
102
|
console.log(' --explain Print NOW ranking explainability to console');
|
|
101
103
|
console.log(' --quiet Suppress non-error logs');
|
|
102
104
|
}
|
package/src/commands/analyze.js
CHANGED
|
@@ -22,12 +22,7 @@ export async function runAnalyzeCommand(options = {}) {
|
|
|
22
22
|
required: [
|
|
23
23
|
{ key: 'githubToken', label: 'GitHub Personal Access Token' }
|
|
24
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
|
-
]
|
|
25
|
+
optional: []
|
|
31
26
|
});
|
|
32
27
|
|
|
33
28
|
let token;
|
|
@@ -65,7 +60,7 @@ export async function runAnalyzeCommand(options = {}) {
|
|
|
65
60
|
|
|
66
61
|
let repositories;
|
|
67
62
|
try {
|
|
68
|
-
repositories = await fetchAllRepositories(github);
|
|
63
|
+
repositories = await fetchAllRepositories(github, asOfDate);
|
|
69
64
|
} catch (err) {
|
|
70
65
|
if (err && (err.status === 401 || err.status === 403)) {
|
|
71
66
|
fatal('GitHub authentication failed — check your GITHUB_TOKEN permissions');
|
package/src/commands/report.js
CHANGED
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) {
|
|
@@ -309,6 +309,7 @@ function buildByStateSummary(reportItems) {
|
|
|
309
309
|
const summary = {
|
|
310
310
|
active: 0,
|
|
311
311
|
stale: 0,
|
|
312
|
+
dormant: 0,
|
|
312
313
|
abandoned: 0,
|
|
313
314
|
archived: 0,
|
|
314
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,17 +1,24 @@
|
|
|
1
|
+
import { daysSince } from '../core/classification.js';
|
|
2
|
+
|
|
1
3
|
const PAGE_SIZE = 100;
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Classifies a fork as active or passive.
|
|
5
|
-
* Active forks
|
|
7
|
+
* Active forks are either ahead of the upstream default branch or recently active
|
|
8
|
+
* when upstream comparison metadata is unavailable.
|
|
6
9
|
*/
|
|
7
|
-
export async function classifyFork(client, repo) {
|
|
10
|
+
export async function classifyFork(client, repo, asOfDate = new Date().toISOString().slice(0, 10)) {
|
|
8
11
|
if (!repo?.fork) {
|
|
9
12
|
return null;
|
|
10
13
|
}
|
|
11
14
|
|
|
12
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
|
+
|
|
13
20
|
if (!parent) {
|
|
14
|
-
return
|
|
21
|
+
return fallbackForkType;
|
|
15
22
|
}
|
|
16
23
|
|
|
17
24
|
const ownerLogin = repo.owner?.login ?? repo.ownerLogin;
|
|
@@ -20,7 +27,7 @@ export async function classifyFork(client, repo) {
|
|
|
20
27
|
const branch = repo.default_branch ?? repo.defaultBranch ?? 'main';
|
|
21
28
|
|
|
22
29
|
if (!ownerLogin || !parentOwner || !repo.name) {
|
|
23
|
-
return
|
|
30
|
+
return fallbackForkType;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
try {
|
|
@@ -30,11 +37,11 @@ export async function classifyFork(client, repo) {
|
|
|
30
37
|
|
|
31
38
|
return (comparison?.ahead_by ?? 0) > 0 ? 'active' : 'passive';
|
|
32
39
|
} catch {
|
|
33
|
-
return
|
|
40
|
+
return fallbackForkType;
|
|
34
41
|
}
|
|
35
42
|
}
|
|
36
43
|
|
|
37
|
-
export async function fetchAllRepositories(client) {
|
|
44
|
+
export async function fetchAllRepositories(client, asOfDate = new Date().toISOString().slice(0, 10)) {
|
|
38
45
|
const repositories = [];
|
|
39
46
|
|
|
40
47
|
for (let page = 1; ; page += 1) {
|
|
@@ -66,7 +73,7 @@ export async function fetchAllRepositories(client) {
|
|
|
66
73
|
const batch = forks.slice(index, index + 5);
|
|
67
74
|
await Promise.all(
|
|
68
75
|
batch.map(async (repository) => {
|
|
69
|
-
repository.forkType = await classifyFork(client, repository);
|
|
76
|
+
repository.forkType = await classifyFork(client, repository, asOfDate);
|
|
70
77
|
})
|
|
71
78
|
);
|
|
72
79
|
}
|
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/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
|
}
|