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.
- package/CHANGELOG.md +26 -0
- package/README.md +686 -0
- package/analyzer.manifest.json +18 -0
- package/bin/github-portfolio-analyzer.js +8 -0
- package/package.json +39 -0
- package/schemas/portfolio-report.schema.json +266 -0
- package/src/cli.js +103 -0
- package/src/commands/analyze.js +172 -0
- package/src/commands/buildPortfolio.js +8 -0
- package/src/commands/ingestIdeas.js +8 -0
- package/src/commands/report.js +202 -0
- package/src/config.js +18 -0
- package/src/core/classification.js +47 -0
- package/src/core/ideas.js +170 -0
- package/src/core/portfolio.js +101 -0
- package/src/core/presentationOverrides.js +40 -0
- package/src/core/report.js +788 -0
- package/src/core/scoring.js +92 -0
- package/src/core/taxonomy.js +155 -0
- package/src/errors.js +7 -0
- package/src/github/client.js +76 -0
- package/src/github/repo-inspection.js +87 -0
- package/src/github/repos.js +58 -0
- package/src/io/csv.js +61 -0
- package/src/io/files.js +39 -0
- package/src/io/markdown.js +153 -0
- package/src/io/report.js +187 -0
- package/src/utils/args.js +32 -0
- package/src/utils/concurrency.js +26 -0
- package/src/utils/header.js +48 -0
- package/src/utils/nextAction.js +28 -0
- package/src/utils/output.js +15 -0
- package/src/utils/retry.js +67 -0
- package/src/utils/slug.js +8 -0
- package/src/utils/time.js +26 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { ensureDirectory, writeTextFile } from './files.js';
|
|
3
|
+
|
|
4
|
+
export async function writeProjectMarkdownFiles(outputDir, items) {
|
|
5
|
+
const projectsDir = path.join(outputDir, 'projects');
|
|
6
|
+
await ensureDirectory(projectsDir);
|
|
7
|
+
|
|
8
|
+
const assigned = new Set();
|
|
9
|
+
|
|
10
|
+
for (const item of items) {
|
|
11
|
+
const fileSlug = uniqueSlug(item.slug, item.type, assigned);
|
|
12
|
+
const filePath = path.join(projectsDir, `${fileSlug}.md`);
|
|
13
|
+
const content = renderProjectMarkdown(item);
|
|
14
|
+
await writeTextFile(filePath, content);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function writePortfolioSummary(outputDir, payload) {
|
|
19
|
+
const summaryPath = path.join(outputDir, 'portfolio-summary.md');
|
|
20
|
+
await writeTextFile(summaryPath, renderPortfolioSummary(payload));
|
|
21
|
+
return summaryPath;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function renderProjectMarkdown(item) {
|
|
25
|
+
const name = item.title ?? item.fullName ?? item.name ?? item.slug;
|
|
26
|
+
const lines = [
|
|
27
|
+
`# ${name}`,
|
|
28
|
+
'',
|
|
29
|
+
'## Metadata',
|
|
30
|
+
`- type: ${item.type}`,
|
|
31
|
+
`- category: ${item.category}`,
|
|
32
|
+
`- state: ${item.state}`,
|
|
33
|
+
`- strategy: ${item.strategy}`,
|
|
34
|
+
`- effort: ${item.effort}`,
|
|
35
|
+
`- value: ${item.value}`,
|
|
36
|
+
`- score: ${item.score ?? 0}`,
|
|
37
|
+
`- nextAction: ${item.nextAction}`,
|
|
38
|
+
''
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
if (item.type === 'repo') {
|
|
42
|
+
lines.push('## Repository');
|
|
43
|
+
lines.push(`- fullName: ${item.fullName}`);
|
|
44
|
+
lines.push(`- url: ${item.htmlUrl ?? 'n/a'}`);
|
|
45
|
+
lines.push(`- language: ${item.language ?? 'n/a'}`);
|
|
46
|
+
lines.push(`- activity: ${item.activity ?? 'n/a'}`);
|
|
47
|
+
lines.push(`- maturity: ${item.maturity ?? 'n/a'}`);
|
|
48
|
+
lines.push('');
|
|
49
|
+
|
|
50
|
+
if (item.structuralHealth) {
|
|
51
|
+
lines.push('## Structural Health');
|
|
52
|
+
lines.push(`- README: ${item.structuralHealth.hasReadme}`);
|
|
53
|
+
lines.push(`- LICENSE: ${item.structuralHealth.hasLicense}`);
|
|
54
|
+
lines.push(`- package.json: ${item.structuralHealth.hasPackageJson}`);
|
|
55
|
+
lines.push(`- tests: ${item.structuralHealth.hasTests}`);
|
|
56
|
+
lines.push(`- CI: ${item.structuralHealth.hasCi}`);
|
|
57
|
+
lines.push('');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (item.type === 'idea') {
|
|
62
|
+
lines.push('## Idea');
|
|
63
|
+
lines.push(`- status: ${item.status ?? 'draft'}`);
|
|
64
|
+
lines.push(`- targetUser: ${item.targetUser ?? 'n/a'}`);
|
|
65
|
+
lines.push(`- problem: ${item.problem ?? 'n/a'}`);
|
|
66
|
+
lines.push(`- scope: ${item.scope ?? 'n/a'}`);
|
|
67
|
+
lines.push(`- mvp: ${item.mvp ?? 'n/a'}`);
|
|
68
|
+
lines.push('');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function renderPortfolioSummary(payload) {
|
|
75
|
+
const { generatedAt, asOfDate, items } = payload;
|
|
76
|
+
const repos = items.filter((item) => item.type === 'repo');
|
|
77
|
+
const ideas = items.filter((item) => item.type === 'idea');
|
|
78
|
+
|
|
79
|
+
const lines = [
|
|
80
|
+
'# Portfolio Summary',
|
|
81
|
+
'',
|
|
82
|
+
`- generatedAt: ${generatedAt}`,
|
|
83
|
+
`- asOfDate: ${asOfDate ?? 'null'}`,
|
|
84
|
+
`- totalProjects: ${items.length}`,
|
|
85
|
+
`- repositories: ${repos.length}`,
|
|
86
|
+
`- ideas: ${ideas.length}`,
|
|
87
|
+
'',
|
|
88
|
+
'## Top 10 by score',
|
|
89
|
+
''
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const topTen = [...items]
|
|
93
|
+
.sort((left, right) => {
|
|
94
|
+
if ((right.score ?? 0) !== (left.score ?? 0)) {
|
|
95
|
+
return (right.score ?? 0) - (left.score ?? 0);
|
|
96
|
+
}
|
|
97
|
+
return left.slug.localeCompare(right.slug);
|
|
98
|
+
})
|
|
99
|
+
.slice(0, 10);
|
|
100
|
+
|
|
101
|
+
if (topTen.length === 0) {
|
|
102
|
+
lines.push('- No projects available.');
|
|
103
|
+
} else {
|
|
104
|
+
topTen.forEach((item, index) => {
|
|
105
|
+
const label = item.title ?? item.fullName ?? item.name ?? item.slug;
|
|
106
|
+
lines.push(`${index + 1}. ${label} (${item.type}) — score ${item.score ?? 0}, state ${item.state}`);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
lines.push('');
|
|
111
|
+
|
|
112
|
+
for (const state of ['active', 'stale', 'abandoned', 'idea']) {
|
|
113
|
+
lines.push(`## State: ${state}`);
|
|
114
|
+
lines.push('');
|
|
115
|
+
|
|
116
|
+
const scoped = items.filter((item) => item.state === state);
|
|
117
|
+
if (scoped.length === 0) {
|
|
118
|
+
lines.push('- None');
|
|
119
|
+
} else {
|
|
120
|
+
scoped.forEach((item) => {
|
|
121
|
+
const label = item.title ?? item.fullName ?? item.name ?? item.slug;
|
|
122
|
+
lines.push(`- ${label} (${item.type}) — score ${item.score ?? 0}`);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
lines.push('');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function uniqueSlug(slug, type, assigned) {
|
|
133
|
+
let candidate = slug;
|
|
134
|
+
if (!assigned.has(candidate)) {
|
|
135
|
+
assigned.add(candidate);
|
|
136
|
+
return candidate;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
candidate = `${slug}-${type}`;
|
|
140
|
+
if (!assigned.has(candidate)) {
|
|
141
|
+
assigned.add(candidate);
|
|
142
|
+
return candidate;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let index = 2;
|
|
146
|
+
while (assigned.has(`${candidate}-${index}`)) {
|
|
147
|
+
index += 1;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const finalValue = `${candidate}-${index}`;
|
|
151
|
+
assigned.add(finalValue);
|
|
152
|
+
return finalValue;
|
|
153
|
+
}
|
package/src/io/report.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { writeJsonFile, writeTextFile } from './files.js';
|
|
3
|
+
import { effortOrder, stateOrder } from '../core/report.js';
|
|
4
|
+
|
|
5
|
+
export async function writeReportJson(outputDir, model) {
|
|
6
|
+
const filePath = path.join(outputDir, 'portfolio-report.json');
|
|
7
|
+
await writeJsonFile(filePath, model);
|
|
8
|
+
return filePath;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function writeReportMarkdown(outputDir, model) {
|
|
12
|
+
const filePath = path.join(outputDir, 'portfolio-report.md');
|
|
13
|
+
await writeTextFile(filePath, renderReportMarkdown(model));
|
|
14
|
+
return filePath;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function writeReportAscii(outputDir, model) {
|
|
18
|
+
const filePath = path.join(outputDir, 'portfolio-report.txt');
|
|
19
|
+
await writeTextFile(filePath, renderReportAscii(model));
|
|
20
|
+
return filePath;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function renderReportAscii(model) {
|
|
24
|
+
const lines = [];
|
|
25
|
+
|
|
26
|
+
lines.push('Portfolio Decision Report');
|
|
27
|
+
lines.push('=========================');
|
|
28
|
+
lines.push(`Generated: ${model.meta.generatedAt}`);
|
|
29
|
+
lines.push(`As-of date: ${model.meta.asOfDate ?? 'null'}`);
|
|
30
|
+
lines.push(`Owner: ${model.meta.owner ?? 'null'}`);
|
|
31
|
+
lines.push(`Counts: total ${model.meta.counts.total}, repos ${model.meta.counts.repos}, ideas ${model.meta.counts.ideas}`);
|
|
32
|
+
lines.push('');
|
|
33
|
+
lines.push(`State bar: ${buildStateBarLine(model.summary.byState)}`);
|
|
34
|
+
lines.push('');
|
|
35
|
+
lines.push('Completion x Effort Matrix');
|
|
36
|
+
lines.push('--------------------------');
|
|
37
|
+
lines.push(renderAsciiMatrix(model.matrix.completionByEffort));
|
|
38
|
+
lines.push('');
|
|
39
|
+
|
|
40
|
+
lines.push(...renderAsciiBandSection('NOW', model.summary.now));
|
|
41
|
+
lines.push(...renderAsciiBandSection('NEXT', model.summary.next));
|
|
42
|
+
lines.push(...renderAsciiBandSection('LATER', model.summary.later));
|
|
43
|
+
lines.push(...renderAsciiBandSection('PARK', model.summary.park));
|
|
44
|
+
|
|
45
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function renderReportMarkdown(model) {
|
|
49
|
+
const lines = [];
|
|
50
|
+
|
|
51
|
+
lines.push('# Portfolio Decision Report');
|
|
52
|
+
lines.push('');
|
|
53
|
+
lines.push(`- Generated: ${model.meta.generatedAt}`);
|
|
54
|
+
lines.push(`- As-of date: ${model.meta.asOfDate ?? 'null'}`);
|
|
55
|
+
lines.push(`- Owner: ${model.meta.owner ?? 'null'}`);
|
|
56
|
+
lines.push(`- Counts: total ${model.meta.counts.total}, repos ${model.meta.counts.repos}, ideas ${model.meta.counts.ideas}`);
|
|
57
|
+
lines.push('');
|
|
58
|
+
|
|
59
|
+
lines.push('## State Bar');
|
|
60
|
+
lines.push('');
|
|
61
|
+
lines.push(buildStateBarLine(model.summary.byState));
|
|
62
|
+
lines.push('');
|
|
63
|
+
|
|
64
|
+
lines.push('## Top 10 by Score');
|
|
65
|
+
lines.push('');
|
|
66
|
+
lines.push('| # | Slug | Type | Score | State | Effort Estimate | Value | CL | Priority | Next Action |');
|
|
67
|
+
lines.push('|---|------|------|-------|-------|-----------------|-------|----|----------|-------------|');
|
|
68
|
+
|
|
69
|
+
const top10 = model.summary.top10ByScore;
|
|
70
|
+
if (top10.length === 0) {
|
|
71
|
+
lines.push('| 1 | - | - | - | - | - | - | - | - | - |');
|
|
72
|
+
} else {
|
|
73
|
+
top10.forEach((item, index) => {
|
|
74
|
+
lines.push(`| ${index + 1} | ${escapeCell(item.slug)} | ${item.type} | ${item.score} | ${item.state} | ${item.effortEstimate} | ${item.value} | ${item.completionLevel} | ${item.priorityBand} | ${escapeCell(item.nextAction)} |`);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
lines.push('');
|
|
79
|
+
lines.push('## Completion x Effort Matrix');
|
|
80
|
+
lines.push('');
|
|
81
|
+
lines.push('| Level | xs | s | m | l | xl |');
|
|
82
|
+
lines.push('|-------|----|---|---|---|----|');
|
|
83
|
+
|
|
84
|
+
for (let level = 0; level <= 5; level += 1) {
|
|
85
|
+
const key = `CL${level}`;
|
|
86
|
+
const row = model.matrix.completionByEffort[key];
|
|
87
|
+
lines.push(`| ${key} | ${row.xs} | ${row.s} | ${row.m} | ${row.l} | ${row.xl} |`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
lines.push('');
|
|
91
|
+
lines.push(...renderMarkdownBandSection('NOW', model.summary.now));
|
|
92
|
+
lines.push(...renderMarkdownBandSection('NEXT', model.summary.next));
|
|
93
|
+
lines.push(...renderMarkdownBandSection('LATER', model.summary.later));
|
|
94
|
+
lines.push(...renderMarkdownBandSection('PARK', model.summary.park));
|
|
95
|
+
|
|
96
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function renderAsciiBandSection(title, items) {
|
|
100
|
+
const lines = [];
|
|
101
|
+
lines.push(`${title}`);
|
|
102
|
+
lines.push('-'.repeat(title.length));
|
|
103
|
+
|
|
104
|
+
if (items.length === 0) {
|
|
105
|
+
lines.push('None');
|
|
106
|
+
lines.push('');
|
|
107
|
+
return lines;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
items.slice(0, 5).forEach((item, index) => {
|
|
111
|
+
lines.push(`${index + 1}) ${item.slug} — Score ${item.score} — CL${item.completionLevel} — Effort ${item.effortEstimate} — State ${item.state}`);
|
|
112
|
+
lines.push(` Why: ${item.priorityWhy?.join('; ') ?? ''}`);
|
|
113
|
+
lines.push(` Next: ${item.nextAction}`);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
lines.push('');
|
|
117
|
+
return lines;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function renderMarkdownBandSection(title, items) {
|
|
121
|
+
const lines = [];
|
|
122
|
+
lines.push(`## ${title}`);
|
|
123
|
+
lines.push('');
|
|
124
|
+
|
|
125
|
+
if (items.length === 0) {
|
|
126
|
+
lines.push('- None');
|
|
127
|
+
lines.push('');
|
|
128
|
+
return lines;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
items.slice(0, 5).forEach((item, index) => {
|
|
132
|
+
lines.push(`${index + 1}. **${item.slug}** — Score ${item.score} — CL${item.completionLevel} — Effort ${item.effortEstimate} — State ${item.state}`);
|
|
133
|
+
lines.push(` - Why: ${item.priorityWhy?.join('; ') ?? ''}`);
|
|
134
|
+
lines.push(` - Next: ${item.nextAction}`);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
lines.push('');
|
|
138
|
+
return lines;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildStateBarLine(byState) {
|
|
142
|
+
const states = stateOrder();
|
|
143
|
+
const counts = states.map((state) => Number(byState[state] ?? 0));
|
|
144
|
+
const maxCount = Math.max(1, ...counts);
|
|
145
|
+
|
|
146
|
+
const segments = states.map((state) => {
|
|
147
|
+
const count = Number(byState[state] ?? 0);
|
|
148
|
+
const blocks = count <= 0 ? '' : '█'.repeat(Math.max(1, Math.round((count / maxCount) * 8)));
|
|
149
|
+
return `${state} ${blocks} ${count}`.replace(/\s+/g, ' ').trim();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return segments.join(' | ');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function renderAsciiMatrix(matrix) {
|
|
156
|
+
const effortKeys = effortOrder();
|
|
157
|
+
const header = ['Level', ...effortKeys.map((effort) => effort.toUpperCase())];
|
|
158
|
+
const widths = [5, 4, 4, 4, 4, 4];
|
|
159
|
+
|
|
160
|
+
const rows = [];
|
|
161
|
+
rows.push(formatRow(header, widths));
|
|
162
|
+
rows.push(formatRow(widths.map((width) => '-'.repeat(width)), widths));
|
|
163
|
+
|
|
164
|
+
for (let level = 0; level <= 5; level += 1) {
|
|
165
|
+
const key = `CL${level}`;
|
|
166
|
+
const row = matrix[key] ?? { xs: 0, s: 0, m: 0, l: 0, xl: 0 };
|
|
167
|
+
rows.push(
|
|
168
|
+
formatRow(
|
|
169
|
+
[key, String(row.xs), String(row.s), String(row.m), String(row.l), String(row.xl)],
|
|
170
|
+
widths
|
|
171
|
+
)
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return rows.join('\n');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function formatRow(cells, widths) {
|
|
179
|
+
return cells
|
|
180
|
+
.map((cell, index) => String(cell).padEnd(widths[index], ' '))
|
|
181
|
+
.join(' ')
|
|
182
|
+
.trimEnd();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function escapeCell(value) {
|
|
186
|
+
return String(value ?? '').replaceAll('|', '\\|');
|
|
187
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function parseArgs(argv) {
|
|
2
|
+
const positional = [];
|
|
3
|
+
const options = {};
|
|
4
|
+
|
|
5
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
6
|
+
const current = argv[index];
|
|
7
|
+
|
|
8
|
+
if (!current.startsWith('--')) {
|
|
9
|
+
positional.push(current);
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const [rawKey, inlineValue] = current.split('=', 2);
|
|
14
|
+
const key = rawKey.replace(/^--/, '');
|
|
15
|
+
|
|
16
|
+
if (inlineValue !== undefined) {
|
|
17
|
+
options[key] = inlineValue;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const next = argv[index + 1];
|
|
22
|
+
if (!next || next.startsWith('--')) {
|
|
23
|
+
options[key] = true;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
options[key] = next;
|
|
28
|
+
index += 1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { positional, options };
|
|
32
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export async function mapWithConcurrency(items, limit, mapper) {
|
|
2
|
+
if (!Array.isArray(items)) {
|
|
3
|
+
return [];
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const concurrency = Math.max(1, Math.floor(limit));
|
|
7
|
+
const results = new Array(items.length);
|
|
8
|
+
let nextIndex = 0;
|
|
9
|
+
|
|
10
|
+
async function worker() {
|
|
11
|
+
while (true) {
|
|
12
|
+
const currentIndex = nextIndex;
|
|
13
|
+
nextIndex += 1;
|
|
14
|
+
|
|
15
|
+
if (currentIndex >= items.length) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker());
|
|
24
|
+
await Promise.all(workers);
|
|
25
|
+
return results;
|
|
26
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { RESET, GRAY, AMBER, GREEN, BLUE, BOLD } from './output.js';
|
|
2
|
+
import packageJson from '../../package.json' with { type: 'json' };
|
|
3
|
+
|
|
4
|
+
const DIM = '\x1b[2m';
|
|
5
|
+
|
|
6
|
+
const ASCII = `${BLUE} ◉──●──●──●──◉${RESET}
|
|
7
|
+
${DIM} \\ /${RESET}
|
|
8
|
+
${AMBER} ◉──◉${RESET}
|
|
9
|
+
${DIM} ↓${RESET}
|
|
10
|
+
${AMBER} now ████ ↑↑↑${RESET}
|
|
11
|
+
${AMBER} next ███░ ↑↑${RESET}
|
|
12
|
+
${AMBER} later█░░░ ↑${RESET}
|
|
13
|
+
${DIM} ↓${RESET}
|
|
14
|
+
${GREEN} ✓ report.json${RESET}`;
|
|
15
|
+
|
|
16
|
+
export function printHeader({ command: _command, asOfDate, outputDir, hasToken, hasPolicy, version }) {
|
|
17
|
+
const node = process.version;
|
|
18
|
+
const user = process.env.GITHUB_USERNAME ?? '—';
|
|
19
|
+
const token = hasToken ? `${GREEN}✓ set${RESET}` : `${AMBER}not set${RESET}`;
|
|
20
|
+
const policy = hasPolicy ? `${GREEN}✓ set${RESET}` : `${GRAY}not set${RESET}`;
|
|
21
|
+
const ver = version ?? packageJson.version;
|
|
22
|
+
|
|
23
|
+
const info = [
|
|
24
|
+
`${BOLD}github-portfolio-analyzer${RESET}`,
|
|
25
|
+
`${GRAY}repo inventory → execution decisions${RESET}`,
|
|
26
|
+
``,
|
|
27
|
+
`${GRAY}version ${RESET}${GREEN}${ver}${RESET}`,
|
|
28
|
+
`${GRAY}node ${RESET}${node}`,
|
|
29
|
+
`${GRAY}user ${RESET}${user}`,
|
|
30
|
+
`${GRAY}as-of ${RESET}${asOfDate ?? 'today UTC'}`,
|
|
31
|
+
`${GRAY}output ${RESET}${outputDir ?? 'output/'}`,
|
|
32
|
+
`${GRAY}token ${RESET}${token}`,
|
|
33
|
+
`${GRAY}policy ${RESET}${policy}`,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const artLines = ASCII.split('\n');
|
|
37
|
+
const maxLines = Math.max(artLines.length, info.length);
|
|
38
|
+
|
|
39
|
+
console.log('');
|
|
40
|
+
for (let i = 0; i < maxLines; i++) {
|
|
41
|
+
const left = (artLines[i] ?? '').padEnd(26);
|
|
42
|
+
const right = info[i] ?? '';
|
|
43
|
+
console.log(` ${left} ${right}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const divider = `${GRAY}${'─'.repeat(56)}${RESET}`;
|
|
47
|
+
console.log(`\n ${divider}\n`);
|
|
48
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function formatNextAction(verb, target, measurableCondition) {
|
|
2
|
+
return `${capitalize(verb)} ${target} — Done when: ${measurableCondition}`;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function normalizeNextAction(value) {
|
|
6
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
7
|
+
throw new Error('Invalid nextAction format. Required: "<Verb> <target> — Done when: <measurable condition>"');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const trimmed = value.trim();
|
|
11
|
+
if (trimmed.includes('— Done when:')) {
|
|
12
|
+
return trimmed;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (trimmed.includes(' - Done when:')) {
|
|
16
|
+
return trimmed.replace(' - Done when:', ' — Done when:');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
throw new Error('Invalid nextAction format. Required: "<Verb> <target> — Done when: <measurable condition>"');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function capitalize(value) {
|
|
23
|
+
if (value.length === 0) {
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
28
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const RESET = '\x1b[0m';
|
|
2
|
+
export const GRAY = '\x1b[90m';
|
|
3
|
+
export const AMBER = '\x1b[33m';
|
|
4
|
+
export const GREEN = '\x1b[32m';
|
|
5
|
+
export const RED = '\x1b[31m';
|
|
6
|
+
export const BLUE = '\x1b[34m';
|
|
7
|
+
export const BOLD = '\x1b[1m';
|
|
8
|
+
|
|
9
|
+
export function info(msg) { console.log(`${GRAY}${msg}${RESET}`); }
|
|
10
|
+
export function progress(msg) { console.log(`${AMBER}${msg}${RESET}`); }
|
|
11
|
+
export function success(msg) { console.log(`${GREEN}${msg}${RESET}`); }
|
|
12
|
+
export function error(msg) { console.error(`${RED}${msg}${RESET}`); }
|
|
13
|
+
export function warn(msg) { console.log(`${AMBER}⚠ ${msg}${RESET}`); }
|
|
14
|
+
export function fatal(msg) { console.error(`${RED}✗ ${msg}${RESET}`); }
|
|
15
|
+
export function dim(msg) { process.stdout.write(`${GRAY}${msg}${RESET}`); }
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const DEFAULT_BASE_DELAY_MS = 500;
|
|
2
|
+
const DEFAULT_MAX_DELAY_MS = 30_000;
|
|
3
|
+
|
|
4
|
+
export function isRetryableStatus(status) {
|
|
5
|
+
return status === 403 || status === 429 || status >= 500;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function computeRetryDelayMs({ responseHeaders, attempt, nowMs = Date.now() }) {
|
|
9
|
+
const retryAfter = parseRetryAfterMs(responseHeaders);
|
|
10
|
+
if (retryAfter !== null) {
|
|
11
|
+
return retryAfter;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const resetDelay = parseRateLimitResetMs(responseHeaders, nowMs);
|
|
15
|
+
if (resetDelay !== null) {
|
|
16
|
+
return resetDelay;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const exponential = DEFAULT_BASE_DELAY_MS * (2 ** attempt);
|
|
20
|
+
const jitter = Math.floor(Math.random() * 250);
|
|
21
|
+
return Math.min(DEFAULT_MAX_DELAY_MS, exponential + jitter);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function sleepMs(delayMs) {
|
|
25
|
+
if (delayMs <= 0) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseRetryAfterMs(headers) {
|
|
33
|
+
if (!headers) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const retryAfterRaw = headers.get('retry-after');
|
|
38
|
+
if (!retryAfterRaw) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const seconds = Number.parseInt(retryAfterRaw, 10);
|
|
43
|
+
if (Number.isNaN(seconds) || seconds < 0) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return seconds * 1000;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseRateLimitResetMs(headers, nowMs) {
|
|
51
|
+
if (!headers) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const resetRaw = headers.get('x-ratelimit-reset');
|
|
56
|
+
if (!resetRaw) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const secondsSinceEpoch = Number.parseInt(resetRaw, 10);
|
|
61
|
+
if (Number.isNaN(secondsSinceEpoch)) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const resetTimeMs = secondsSinceEpoch * 1000;
|
|
66
|
+
return Math.max(0, resetTimeMs - nowMs);
|
|
67
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
2
|
+
|
|
3
|
+
export function utcTodayDateString() {
|
|
4
|
+
return new Date().toISOString().slice(0, 10);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function resolveAsOfDate(input) {
|
|
8
|
+
if (typeof input === 'string' && input.length > 0) {
|
|
9
|
+
if (!ISO_DATE_PATTERN.test(input)) {
|
|
10
|
+
throw new Error(`Invalid --as-of value: ${input}. Expected YYYY-MM-DD.`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const asDate = new Date(`${input}T00:00:00.000Z`);
|
|
14
|
+
if (Number.isNaN(asDate.getTime())) {
|
|
15
|
+
throw new Error(`Invalid --as-of value: ${input}. Expected YYYY-MM-DD.`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return input;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return utcTodayDateString();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function utcNowISOString() {
|
|
25
|
+
return new Date().toISOString();
|
|
26
|
+
}
|