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,202 @@
1
+ import path from 'node:path';
2
+ import { buildReportModel } from '../core/report.js';
3
+ import { loadPresentationOverrides, applyPresentationOverrides } from '../core/presentationOverrides.js';
4
+ import { readJsonFile, readJsonFileIfExists } from '../io/files.js';
5
+ import { writeReportAscii, writeReportJson, writeReportMarkdown } from '../io/report.js';
6
+ import { UsageError } from '../errors.js';
7
+ import { printHeader } from '../utils/header.js';
8
+ import { success } from '../utils/output.js';
9
+
10
+ const ALLOWED_FORMATS = new Set(['ascii', 'md', 'json', 'all']);
11
+
12
+ export async function runReportCommand(options = {}) {
13
+ const inputDir = typeof options['output-dir'] === 'string' ? options['output-dir'] : 'output';
14
+ const outputDir = typeof options.output === 'string' ? options.output : inputDir;
15
+ const formatOption = typeof options.format === 'string' ? options.format.toLowerCase() : 'all';
16
+ const policyPath = resolvePolicyPath(options);
17
+ const explain = options.explain === true;
18
+ const quiet = options.quiet === true || options.quiet === 'true';
19
+
20
+ if (!quiet) {
21
+ printHeader({
22
+ command: 'report',
23
+ outputDir,
24
+ hasToken: false,
25
+ hasPolicy: Boolean(policyPath),
26
+ });
27
+ }
28
+
29
+ if (!ALLOWED_FORMATS.has(formatOption)) {
30
+ throw new UsageError('Invalid --format value. Allowed values: ascii|md|json|all');
31
+ }
32
+
33
+ const portfolioPath = path.join(inputDir, 'portfolio.json');
34
+ const inventoryPath = path.join(inputDir, 'inventory.json');
35
+
36
+ const portfolio = await readJsonFile(portfolioPath).catch((error) => {
37
+ if (error && error.code === 'ENOENT') {
38
+ throw new Error(`Missing required input: ${portfolioPath}. Run build-portfolio before report.`);
39
+ }
40
+
41
+ throw error;
42
+ });
43
+
44
+ const inventory = await readJsonFileIfExists(inventoryPath);
45
+ const policyOverlay = await loadPolicyOverlay(policyPath);
46
+ const presentationOverridesPath = resolvePresentationOverridesPath(options);
47
+ const presentationOverrides = await loadPresentationOverrides(presentationOverridesPath);
48
+ const reportModel = buildReportModel(portfolio, inventory, { policyOverlay });
49
+
50
+ if (presentationOverrides.size > 0) {
51
+ reportModel.items = applyPresentationOverrides(reportModel.items, presentationOverrides);
52
+ }
53
+
54
+ const writtenPaths = [];
55
+
56
+ if (formatOption === 'json' || formatOption === 'all') {
57
+ writtenPaths.push(await writeReportJson(outputDir, reportModel));
58
+ }
59
+
60
+ if (formatOption === 'md' || formatOption === 'all') {
61
+ writtenPaths.push(await writeReportMarkdown(outputDir, reportModel));
62
+ }
63
+
64
+ if (formatOption === 'ascii' || formatOption === 'all') {
65
+ writtenPaths.push(await writeReportAscii(outputDir, reportModel));
66
+ }
67
+
68
+ if (!quiet) {
69
+ success(`✓ Generated portfolio decision report for ${reportModel.meta.counts.total} items`);
70
+ for (const filePath of writtenPaths) {
71
+ success(`✓ Wrote ${filePath}`);
72
+ }
73
+ }
74
+
75
+ if (formatOption === 'json') {
76
+ process.stdout.write(`${JSON.stringify(reportModel, null, 2)}\n`);
77
+ }
78
+
79
+ if (explain && !quiet) {
80
+ printNowExplain(reportModel);
81
+ }
82
+ }
83
+
84
+ function resolvePresentationOverridesPath(options) {
85
+ if (typeof options['presentation-overrides'] === 'string') {
86
+ return options['presentation-overrides'];
87
+ }
88
+ return 'priorities/presentation-overrides.json';
89
+ }
90
+
91
+ function resolvePolicyPath(options) {
92
+ if (typeof options.policy === 'string') {
93
+ return options.policy;
94
+ }
95
+
96
+ if (typeof options.priorities === 'string') {
97
+ return options.priorities;
98
+ }
99
+
100
+ return null;
101
+ }
102
+
103
+ async function loadPolicyOverlay(policyPath) {
104
+ if (!policyPath) {
105
+ return null;
106
+ }
107
+
108
+ const policy = await readJsonFile(policyPath).catch((error) => {
109
+ if (error && error.code === 'ENOENT') {
110
+ throw new Error(`Policy file not found: ${policyPath}`);
111
+ }
112
+
113
+ throw error;
114
+ });
115
+
116
+ validatePolicyOverlay(policy, policyPath);
117
+ return policy;
118
+ }
119
+
120
+ function validatePolicyOverlay(policy, policyPath) {
121
+ if (policy === null || typeof policy !== 'object' || Array.isArray(policy)) {
122
+ throw new Error(`Invalid policy file at ${policyPath}: expected a JSON object.`);
123
+ }
124
+
125
+ if (policy.version !== undefined && policy.version !== 1) {
126
+ throw new Error(`Invalid policy file at ${policyPath}: unsupported version ${policy.version}. Expected 1.`);
127
+ }
128
+ }
129
+
130
+ function printNowExplain(reportModel) {
131
+ const nowItems = Array.isArray(reportModel?.items)
132
+ ? reportModel.items.filter((item) => item.priorityBand === 'now')
133
+ : [];
134
+
135
+ console.log('');
136
+ console.log('Explain mode: NOW ranking');
137
+ if (nowItems.length === 0) {
138
+ console.log('No items in NOW band.');
139
+ return;
140
+ }
141
+
142
+ for (const item of nowItems) {
143
+ const boosts = (item.priorityOverrides ?? [])
144
+ .filter((entry) => Number(entry.boost ?? 0) !== 0)
145
+ .map((entry) => `${formatSignedNumber(entry.boost)} (${entry.ruleId})`);
146
+ const reasons = (item.priorityOverrides ?? [])
147
+ .map((entry) => entry.reason)
148
+ .filter((entry) => typeof entry === 'string' && entry.trim().length > 0);
149
+
150
+ console.log(item.slug);
151
+ console.log(` Base score: ${item.basePriorityScore}`);
152
+ console.log(` Repo score: ${item.score}`);
153
+ console.log(` Signals: ${buildBaseSignalsLine(item)}`);
154
+ console.log(` Boosts: ${boosts.length > 0 ? boosts.join(', ') : 'none'}`);
155
+ console.log(` Final score: ${item.finalPriorityScore}`);
156
+ console.log(` Final band: ${item.priorityBand}`);
157
+ console.log(` Reason(s): ${reasons.length > 0 ? reasons.join('; ') : 'none'}`);
158
+ console.log(` Next: ${item.nextAction}`);
159
+ }
160
+ }
161
+
162
+ function buildBaseSignalsLine(item) {
163
+ const state = String(item.state ?? '').toLowerCase();
164
+ const completionLevel = Number(item.completionLevel ?? 0);
165
+ const effortEstimate = String(item.effortEstimate ?? '').toLowerCase();
166
+
167
+ const stateAdjustment = computeStateAdjustment(state);
168
+ const completionAdjustment = completionLevel >= 1 && completionLevel <= 3 ? 10 : 0;
169
+ const effortAdjustment = effortEstimate === 'l' || effortEstimate === 'xl' ? -10 : 0;
170
+
171
+ return [
172
+ `score=${Number(item.score ?? 0)}`,
173
+ `state(${state || 'unknown'})=${formatSignedNumber(stateAdjustment)}`,
174
+ `completion(CL${completionLevel})=${formatSignedNumber(completionAdjustment)}`,
175
+ `effort(${effortEstimate || 'm'})=${formatSignedNumber(effortAdjustment)}`
176
+ ].join('; ');
177
+ }
178
+
179
+ function computeStateAdjustment(state) {
180
+ if (state === 'active') {
181
+ return 10;
182
+ }
183
+
184
+ if (state === 'stale') {
185
+ return 5;
186
+ }
187
+
188
+ if (state === 'abandoned' || state === 'archived') {
189
+ return -20;
190
+ }
191
+
192
+ return 0;
193
+ }
194
+
195
+ function formatSignedNumber(value) {
196
+ const numeric = Number(value);
197
+ if (!Number.isFinite(numeric)) {
198
+ return '+0';
199
+ }
200
+
201
+ return numeric >= 0 ? `+${numeric}` : String(numeric);
202
+ }
package/src/config.js ADDED
@@ -0,0 +1,18 @@
1
+ import dotenv from 'dotenv';
2
+
3
+ dotenv.config({ quiet: true });
4
+
5
+ export function getEnv() {
6
+ return {
7
+ githubToken: process.env.GITHUB_TOKEN ?? '',
8
+ githubUsername: process.env.GITHUB_USERNAME ?? ''
9
+ };
10
+ }
11
+
12
+ export function requireGithubToken(env = getEnv()) {
13
+ if (!env.githubToken) {
14
+ throw new Error('Missing GITHUB_TOKEN. Add it to your .env file before running analyze.');
15
+ }
16
+
17
+ return env.githubToken;
18
+ }
@@ -0,0 +1,47 @@
1
+ const DAY_IN_MS = 24 * 60 * 60 * 1000;
2
+
3
+ export function classifyActivity(pushedAt, asOfDate) {
4
+ const ageDays = daysSince(pushedAt, asOfDate);
5
+
6
+ if (ageDays <= 90) {
7
+ return 'active';
8
+ }
9
+
10
+ if (ageDays <= 365) {
11
+ return 'stale';
12
+ }
13
+
14
+ return 'abandoned';
15
+ }
16
+
17
+ export function classifyMaturity(sizeKb) {
18
+ if (sizeKb < 500) {
19
+ return 'experimental';
20
+ }
21
+
22
+ if (sizeKb <= 5000) {
23
+ return 'early';
24
+ }
25
+
26
+ if (sizeKb <= 50000) {
27
+ return 'structured';
28
+ }
29
+
30
+ return 'large';
31
+ }
32
+
33
+ export function daysSince(dateInput, asOfDate) {
34
+ const source = new Date(dateInput);
35
+ const reference = new Date(`${asOfDate}T00:00:00.000Z`);
36
+
37
+ if (Number.isNaN(source.getTime()) || Number.isNaN(reference.getTime())) {
38
+ return Number.POSITIVE_INFINITY;
39
+ }
40
+
41
+ const diffMs = reference.getTime() - source.getTime();
42
+ if (diffMs <= 0) {
43
+ return 0;
44
+ }
45
+
46
+ return Math.floor(diffMs / DAY_IN_MS);
47
+ }
@@ -0,0 +1,170 @@
1
+ import path from 'node:path';
2
+ import readline from 'node:readline/promises';
3
+ import { stdin as input, stdout as output } from 'node:process';
4
+ import { readJsonFileIfExists, writeJsonFile } from '../io/files.js';
5
+ import { scoreIdea } from './scoring.js';
6
+ import { buildIdeaTaxonomy } from './taxonomy.js';
7
+ import { normalizeNextAction } from '../utils/nextAction.js';
8
+ import { slugify } from '../utils/slug.js';
9
+ import { utcNowISOString } from '../utils/time.js';
10
+
11
+ export async function ingestIdeas(options = {}) {
12
+ const outputDir = typeof options['output-dir'] === 'string' ? options['output-dir'] : 'output';
13
+ const inputPath = typeof options.input === 'string' ? options.input : path.join('ideas', 'input.json');
14
+ const shouldPrompt = options.prompt === true;
15
+
16
+ const existingIdeas = await loadExistingIdeas(path.join(outputDir, 'ideas.json'));
17
+ const fromFile = await loadIdeasFromFile(inputPath);
18
+ const fromPrompt = shouldPrompt ? await promptIdeas() : [];
19
+ const incomingIdeas = [...fromFile, ...fromPrompt];
20
+
21
+ if (incomingIdeas.length === 0 && existingIdeas.length === 0) {
22
+ throw new Error(`No ideas to ingest. Add entries to ${inputPath} or use --prompt.`);
23
+ }
24
+
25
+ const merged = new Map(existingIdeas.map((item) => [item.slug, item]));
26
+
27
+ for (const rawIdea of incomingIdeas) {
28
+ const normalized = normalizeIdea(rawIdea);
29
+ const taxonomy = buildIdeaTaxonomy(normalized);
30
+ const scored = scoreIdea({
31
+ ...normalized,
32
+ nextAction: taxonomy.nextAction
33
+ });
34
+
35
+ const item = {
36
+ ...normalized,
37
+ ...taxonomy,
38
+ score: scored.score,
39
+ scoreBreakdown: scored.scoreBreakdown
40
+ };
41
+
42
+ merged.set(item.slug, item);
43
+ }
44
+
45
+ const items = Array.from(merged.values()).sort((left, right) => left.slug.localeCompare(right.slug));
46
+ const outputPath = path.join(outputDir, 'ideas.json');
47
+
48
+ await writeJsonFile(outputPath, {
49
+ meta: {
50
+ generatedAt: utcNowISOString(),
51
+ count: items.length
52
+ },
53
+ items
54
+ });
55
+
56
+ return { outputPath, count: items.length };
57
+ }
58
+
59
+ async function loadExistingIdeas(filePath) {
60
+ const value = await readJsonFileIfExists(filePath);
61
+ if (!value || !Array.isArray(value.items)) {
62
+ return [];
63
+ }
64
+
65
+ return value.items;
66
+ }
67
+
68
+ async function loadIdeasFromFile(filePath) {
69
+ const value = await readJsonFileIfExists(filePath);
70
+ if (!value) {
71
+ return [];
72
+ }
73
+
74
+ if (!Array.isArray(value)) {
75
+ throw new Error(`Ideas input must be a JSON array: ${filePath}`);
76
+ }
77
+
78
+ return value;
79
+ }
80
+
81
+ function normalizeIdea(inputIdea) {
82
+ const title = String(inputIdea?.title ?? '').trim();
83
+ if (!title) {
84
+ throw new Error('Each idea must include a non-empty title.');
85
+ }
86
+
87
+ const slug = slugify(inputIdea.slug || title);
88
+ if (!slug) {
89
+ throw new Error(`Unable to derive slug for idea title: ${title}`);
90
+ }
91
+
92
+ const nextActionRaw = typeof inputIdea.nextAction === 'string' ? inputIdea.nextAction : '';
93
+
94
+ return {
95
+ id: `idea:${slug}`,
96
+ slug,
97
+ title,
98
+ description: textOrNull(inputIdea.description),
99
+ problem: textOrNull(inputIdea.problem),
100
+ scope: textOrNull(inputIdea.scope),
101
+ targetUser: textOrNull(inputIdea.targetUser),
102
+ mvp: textOrNull(inputIdea.mvp),
103
+ status: textOrDefault(inputIdea.status, 'draft'),
104
+ tags: normalizeTags(inputIdea.tags),
105
+ category: textOrNull(inputIdea.category),
106
+ state: textOrNull(inputIdea.state),
107
+ strategy: textOrNull(inputIdea.strategy),
108
+ effort: textOrNull(inputIdea.effort),
109
+ value: textOrNull(inputIdea.value),
110
+ ...(nextActionRaw.trim().length > 0 ? { nextAction: normalizeNextAction(nextActionRaw) } : {})
111
+ };
112
+ }
113
+
114
+ async function promptIdeas() {
115
+ const rl = readline.createInterface({ input, output });
116
+ const results = [];
117
+
118
+ try {
119
+ while (true) {
120
+ const title = (await rl.question('Idea title (leave empty to finish): ')).trim();
121
+ if (!title) {
122
+ break;
123
+ }
124
+
125
+ const problem = (await rl.question('Problem: ')).trim();
126
+ const scope = (await rl.question('Scope: ')).trim();
127
+ const targetUser = (await rl.question('Target user: ')).trim();
128
+ const mvp = (await rl.question('MVP: ')).trim();
129
+ const nextAction = (await rl.question('Next action (<Verb> <target> — Done when: <measurable condition>): ')).trim();
130
+
131
+ results.push({
132
+ title,
133
+ problem,
134
+ scope,
135
+ targetUser,
136
+ mvp,
137
+ nextAction: nextAction || undefined
138
+ });
139
+ }
140
+ } finally {
141
+ rl.close();
142
+ }
143
+
144
+ return results;
145
+ }
146
+
147
+ function textOrNull(value) {
148
+ if (typeof value !== 'string') {
149
+ return null;
150
+ }
151
+
152
+ const trimmed = value.trim();
153
+ return trimmed.length > 0 ? trimmed : null;
154
+ }
155
+
156
+ function textOrDefault(value, defaultValue) {
157
+ const normalized = textOrNull(value);
158
+ return normalized ?? defaultValue;
159
+ }
160
+
161
+ function normalizeTags(value) {
162
+ if (!Array.isArray(value)) {
163
+ return [];
164
+ }
165
+
166
+ return value
167
+ .map((item) => String(item).trim().toLowerCase())
168
+ .filter((item) => item.length > 0)
169
+ .sort((left, right) => left.localeCompare(right));
170
+ }
@@ -0,0 +1,101 @@
1
+ import path from 'node:path';
2
+ import { readJsonFileIfExists, writeJsonFile } from '../io/files.js';
3
+ import { writePortfolioSummary, writeProjectMarkdownFiles } from '../io/markdown.js';
4
+ import { buildRepoTaxonomy, buildIdeaTaxonomy } from './taxonomy.js';
5
+ import { slugify } from '../utils/slug.js';
6
+ import { utcNowISOString } from '../utils/time.js';
7
+
8
+ export async function buildPortfolio(options = {}) {
9
+ const outputDir = typeof options['output-dir'] === 'string' ? options['output-dir'] : 'output';
10
+ const inventoryPath = path.join(outputDir, 'inventory.json');
11
+ const ideasPath = path.join(outputDir, 'ideas.json');
12
+ const portfolioPath = path.join(outputDir, 'portfolio.json');
13
+
14
+ const inventoryData = await readJsonFileIfExists(inventoryPath);
15
+ const ideasData = await readJsonFileIfExists(ideasPath);
16
+
17
+ const repos = Array.isArray(inventoryData?.items)
18
+ ? inventoryData.items.map((item) => hydrateRepoItem(item))
19
+ : [];
20
+ const ideas = Array.isArray(ideasData?.items)
21
+ ? ideasData.items.map((item) => hydrateIdeaItem(item))
22
+ : [];
23
+
24
+ const items = [...repos, ...ideas].sort((left, right) => {
25
+ if ((right.score ?? 0) !== (left.score ?? 0)) {
26
+ return (right.score ?? 0) - (left.score ?? 0);
27
+ }
28
+
29
+ return left.slug.localeCompare(right.slug);
30
+ });
31
+
32
+ const generatedAt = utcNowISOString();
33
+ const asOfDate = inventoryData?.meta?.asOfDate ?? null;
34
+
35
+ const payload = {
36
+ meta: {
37
+ generatedAt,
38
+ asOfDate,
39
+ count: items.length
40
+ },
41
+ items
42
+ };
43
+
44
+ await writeJsonFile(portfolioPath, payload);
45
+ await writeProjectMarkdownFiles(outputDir, items);
46
+ await writePortfolioSummary(outputDir, {
47
+ generatedAt,
48
+ asOfDate,
49
+ items
50
+ });
51
+
52
+ return { portfolioPath, count: items.length };
53
+ }
54
+
55
+ function hydrateRepoItem(item) {
56
+ const slug = slugify(item.slug || item.fullName || item.name || String(item.id));
57
+ const taxonomy = hasTaxonomy(item) ? pickTaxonomy(item) : buildRepoTaxonomy(item);
58
+
59
+ return {
60
+ ...item,
61
+ slug,
62
+ ...taxonomy,
63
+ type: 'repo'
64
+ };
65
+ }
66
+
67
+ function hydrateIdeaItem(item) {
68
+ const slug = slugify(item.slug || item.title || String(item.id));
69
+ const taxonomy = hasTaxonomy(item) ? pickTaxonomy(item) : buildIdeaTaxonomy(item);
70
+
71
+ return {
72
+ ...item,
73
+ slug,
74
+ ...taxonomy,
75
+ type: 'idea'
76
+ };
77
+ }
78
+
79
+ function hasTaxonomy(item) {
80
+ return Boolean(
81
+ item?.category &&
82
+ item?.state &&
83
+ item?.strategy &&
84
+ item?.effort &&
85
+ item?.value &&
86
+ item?.nextAction &&
87
+ item?.taxonomyMeta
88
+ );
89
+ }
90
+
91
+ function pickTaxonomy(item) {
92
+ return {
93
+ category: item.category,
94
+ state: item.state,
95
+ strategy: item.strategy,
96
+ effort: item.effort,
97
+ value: item.value,
98
+ nextAction: item.nextAction,
99
+ taxonomyMeta: item.taxonomyMeta
100
+ };
101
+ }
@@ -0,0 +1,40 @@
1
+ import { readJsonFileIfExists } from '../io/files.js';
2
+
3
+ const VALID_PRESENTATION_STATES = new Set([
4
+ 'featured',
5
+ 'complete',
6
+ 'in-progress',
7
+ 'salvageable',
8
+ 'learning',
9
+ 'archived',
10
+ 'hidden'
11
+ ]);
12
+
13
+ export async function loadPresentationOverrides(filePath) {
14
+ const data = await readJsonFileIfExists(filePath);
15
+ if (!data) return new Map();
16
+
17
+ if (!Array.isArray(data.items)) return new Map();
18
+
19
+ const map = new Map();
20
+ for (const entry of data.items) {
21
+ const slug = typeof entry?.slug === 'string' ? entry.slug.trim() : null;
22
+ const state = typeof entry?.presentationState === 'string'
23
+ ? entry.presentationState.trim()
24
+ : null;
25
+
26
+ if (!slug || !state) continue;
27
+ if (!VALID_PRESENTATION_STATES.has(state)) continue;
28
+
29
+ map.set(slug, { presentationState: state });
30
+ }
31
+ return map;
32
+ }
33
+
34
+ export function applyPresentationOverrides(items, overridesMap) {
35
+ return items.map((item) => {
36
+ const override = overridesMap.get(item.slug);
37
+ if (!override) return item;
38
+ return { ...item, ...override };
39
+ });
40
+ }