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,92 @@
1
+ import { daysSince } from './classification.js';
2
+
3
+ export function scoreRepository(repository, asOfDate) {
4
+ let score = 0;
5
+ const breakdown = {
6
+ pushedWithin90Days: 0,
7
+ hasReadme: 0,
8
+ hasLicense: 0,
9
+ hasTests: 0,
10
+ starsOverOne: 0,
11
+ updatedWithin180Days: 0
12
+ };
13
+
14
+ if (daysSince(repository.pushedAt, asOfDate) <= 90) {
15
+ score += 30;
16
+ breakdown.pushedWithin90Days = 30;
17
+ }
18
+
19
+ if (repository.structuralHealth?.hasReadme) {
20
+ score += 15;
21
+ breakdown.hasReadme = 15;
22
+ }
23
+
24
+ if (repository.structuralHealth?.hasLicense) {
25
+ score += 10;
26
+ breakdown.hasLicense = 10;
27
+ }
28
+
29
+ if (repository.structuralHealth?.hasTests) {
30
+ score += 20;
31
+ breakdown.hasTests = 20;
32
+ }
33
+
34
+ if ((repository.stargazersCount ?? 0) > 1) {
35
+ score += 5;
36
+ breakdown.starsOverOne = 5;
37
+ }
38
+
39
+ if (daysSince(repository.updatedAt, asOfDate) <= 180) {
40
+ score += 20;
41
+ breakdown.updatedWithin180Days = 20;
42
+ }
43
+
44
+ return {
45
+ score: clamp(score, 0, 100),
46
+ scoreBreakdown: breakdown
47
+ };
48
+ }
49
+
50
+ export function scoreIdea(idea) {
51
+ let score = 30;
52
+ const breakdown = {
53
+ baseline: 30,
54
+ scopeOrProblem: 0,
55
+ targetUser: 0,
56
+ mvp: 0,
57
+ nextAction: 0
58
+ };
59
+
60
+ if (hasText(idea.scope) || hasText(idea.problem)) {
61
+ score += 20;
62
+ breakdown.scopeOrProblem = 20;
63
+ }
64
+
65
+ if (hasText(idea.targetUser)) {
66
+ score += 15;
67
+ breakdown.targetUser = 15;
68
+ }
69
+
70
+ if (hasText(idea.mvp)) {
71
+ score += 15;
72
+ breakdown.mvp = 15;
73
+ }
74
+
75
+ if (hasText(idea.nextAction)) {
76
+ score += 20;
77
+ breakdown.nextAction = 20;
78
+ }
79
+
80
+ return {
81
+ score: clamp(score, 0, 100),
82
+ scoreBreakdown: breakdown
83
+ };
84
+ }
85
+
86
+ function hasText(value) {
87
+ return typeof value === 'string' && value.trim().length > 0;
88
+ }
89
+
90
+ function clamp(value, min, max) {
91
+ return Math.min(max, Math.max(min, value));
92
+ }
@@ -0,0 +1,155 @@
1
+ import { formatNextAction, normalizeNextAction } from '../utils/nextAction.js';
2
+
3
+ export function buildRepoTaxonomy(repository) {
4
+ const activityState = repository.activity;
5
+ const state = repository.archived ? 'archived' : normalizeState(activityState, 'active');
6
+
7
+ const category = 'tooling';
8
+ const strategy = 'maintenance';
9
+ const effort = 'm';
10
+ const value = 'medium';
11
+ const nextAction = defaultRepoNextAction(state);
12
+
13
+ const sources = {
14
+ category: 'default',
15
+ state: repository.archived ? 'inferred' : 'inferred',
16
+ strategy: 'default',
17
+ effort: 'default',
18
+ value: 'default',
19
+ nextAction: 'default'
20
+ };
21
+
22
+ return {
23
+ type: 'repo',
24
+ category,
25
+ state,
26
+ strategy,
27
+ effort,
28
+ value,
29
+ nextAction: normalizeNextAction(nextAction),
30
+ taxonomyMeta: {
31
+ defaulted: Object.values(sources).includes('default'),
32
+ sources,
33
+ inferenceSignals: repository.archived ? ['repository.archived=true'] : ['activity classification']
34
+ }
35
+ };
36
+ }
37
+
38
+ export function buildIdeaTaxonomy(idea) {
39
+ const inferredState = mapIdeaStatusToState(idea.status);
40
+
41
+ const category = normalizeCategory(idea.category) ?? 'experiment';
42
+ const state = normalizeState(idea.state, inferredState);
43
+ const strategy = normalizeStrategy(idea.strategy) ?? 'parked';
44
+ const effort = normalizeEffort(idea.effort) ?? 'm';
45
+ const value = normalizeValue(idea.value) ?? 'medium';
46
+ const nextAction = normalizeNextAction(
47
+ typeof idea.nextAction === 'string' && idea.nextAction.trim().length > 0
48
+ ? idea.nextAction
49
+ : formatNextAction('Define', 'MVP in 7 bullets', 'issue includes scope, target user, and acceptance checks')
50
+ );
51
+
52
+ const sources = {
53
+ category: normalizeCategory(idea.category) ? 'user' : 'default',
54
+ state: normalizeState(idea.state, null) ? 'user' : 'inferred',
55
+ strategy: normalizeStrategy(idea.strategy) ? 'user' : 'default',
56
+ effort: normalizeEffort(idea.effort) ? 'user' : 'default',
57
+ value: normalizeValue(idea.value) ? 'user' : 'default',
58
+ nextAction: typeof idea.nextAction === 'string' && idea.nextAction.trim().length > 0 ? 'user' : 'default'
59
+ };
60
+
61
+ const inferenceSignals = [];
62
+ if (sources.state === 'inferred') {
63
+ inferenceSignals.push('status mapping');
64
+ }
65
+
66
+ return {
67
+ type: 'idea',
68
+ category,
69
+ state,
70
+ strategy,
71
+ effort,
72
+ value,
73
+ nextAction,
74
+ taxonomyMeta: {
75
+ defaulted: Object.values(sources).includes('default'),
76
+ sources,
77
+ ...(inferenceSignals.length > 0 ? { inferenceSignals } : {})
78
+ }
79
+ };
80
+ }
81
+
82
+ export function mapIdeaStatusToState(status) {
83
+ const value = String(status ?? '').trim().toLowerCase();
84
+
85
+ if (value === 'active' || value === 'in-progress') {
86
+ return 'active';
87
+ }
88
+
89
+ if (value === 'stale' || value === 'on-hold') {
90
+ return 'stale';
91
+ }
92
+
93
+ if (value === 'abandoned' || value === 'dropped') {
94
+ return 'abandoned';
95
+ }
96
+
97
+ if (value === 'archived') {
98
+ return 'archived';
99
+ }
100
+
101
+ if (value === 'reference-only' || value === 'reference') {
102
+ return 'reference-only';
103
+ }
104
+
105
+ return 'idea';
106
+ }
107
+
108
+ function defaultRepoNextAction(state) {
109
+ if (state === 'active') {
110
+ return formatNextAction('Ship', 'one meaningful improvement', 'one PR with tests and updated docs is merged');
111
+ }
112
+
113
+ if (state === 'stale') {
114
+ return formatNextAction('Refresh', 'execution documentation', 'README run steps are validated in a clean environment');
115
+ }
116
+
117
+ if (state === 'abandoned') {
118
+ return formatNextAction('Decide', 'retain or archive status', 'README contains a documented decision and rationale');
119
+ }
120
+
121
+ if (state === 'archived') {
122
+ return formatNextAction('Document', 'reference usage', 'README states archive reason and replacement options');
123
+ }
124
+
125
+ return formatNextAction('Clarify', 'project direction', 'next milestone and owner are documented');
126
+ }
127
+
128
+ function normalizeCategory(value) {
129
+ return normalizeEnum(value, ['product', 'tooling', 'library', 'learning', 'content', 'infra', 'experiment', 'template']);
130
+ }
131
+
132
+ function normalizeState(value, fallback) {
133
+ return normalizeEnum(value, ['idea', 'active', 'stale', 'abandoned', 'archived', 'reference-only']) ?? fallback;
134
+ }
135
+
136
+ function normalizeStrategy(value) {
137
+ return normalizeEnum(value, ['strategic-core', 'strategic-support', 'opportunistic', 'maintenance', 'parked']);
138
+ }
139
+
140
+ function normalizeEffort(value) {
141
+ return normalizeEnum(value, ['xs', 's', 'm', 'l', 'xl']);
142
+ }
143
+
144
+ function normalizeValue(value) {
145
+ return normalizeEnum(value, ['low', 'medium', 'high', 'very-high']);
146
+ }
147
+
148
+ function normalizeEnum(value, allowed) {
149
+ const normalized = String(value ?? '').trim().toLowerCase();
150
+ if (!normalized) {
151
+ return null;
152
+ }
153
+
154
+ return allowed.includes(normalized) ? normalized : null;
155
+ }
package/src/errors.js ADDED
@@ -0,0 +1,7 @@
1
+ export class UsageError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = 'UsageError';
5
+ this.exitCode = 2;
6
+ }
7
+ }
@@ -0,0 +1,76 @@
1
+ const GITHUB_API_BASE_URL = 'https://api.github.com';
2
+ import { computeRetryDelayMs, isRetryableStatus, sleepMs } from '../utils/retry.js';
3
+
4
+ export class GithubApiError extends Error {
5
+ constructor(message, options) {
6
+ super(message);
7
+ this.name = 'GithubApiError';
8
+ this.status = options.status;
9
+ this.data = options.data;
10
+ this.headers = options.headers;
11
+ }
12
+ }
13
+
14
+ export class GithubClient {
15
+ constructor(token) {
16
+ this.token = token;
17
+ this.maxRetries = 4;
18
+ }
19
+
20
+ async request(path, init = {}) {
21
+ const url = `${GITHUB_API_BASE_URL}${path}`;
22
+ const requestInit = {
23
+ ...init,
24
+ headers: {
25
+ Accept: 'application/vnd.github+json',
26
+ Authorization: `Bearer ${this.token}`,
27
+ 'User-Agent': 'github-portfolio-analyzer',
28
+ ...init.headers
29
+ }
30
+ };
31
+
32
+ for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) {
33
+ const response = await fetch(url, requestInit);
34
+ const data = await safeJson(response);
35
+
36
+ if (response.ok) {
37
+ return data;
38
+ }
39
+
40
+ if (attempt < this.maxRetries && isRetryableStatus(response.status)) {
41
+ const delayMs = computeRetryDelayMs({
42
+ responseHeaders: response.headers,
43
+ attempt
44
+ });
45
+ await sleepMs(delayMs);
46
+ continue;
47
+ }
48
+
49
+ const details = data?.message ? ` (${data.message})` : '';
50
+ throw new GithubApiError(`GitHub API request failed: ${response.status}${details}`, {
51
+ status: response.status,
52
+ data,
53
+ headers: response.headers
54
+ });
55
+ }
56
+
57
+ throw new Error('GitHub request retry loop exited unexpectedly.');
58
+ }
59
+
60
+ async getAuthenticatedUser() {
61
+ return this.request('/user');
62
+ }
63
+ }
64
+
65
+ async function safeJson(response) {
66
+ const contentType = response.headers.get('content-type') ?? '';
67
+ if (!contentType.includes('application/json')) {
68
+ return null;
69
+ }
70
+
71
+ try {
72
+ return await response.json();
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
@@ -0,0 +1,87 @@
1
+ import { GithubApiError } from './client.js';
2
+
3
+ export async function inspectRepositoryStructure(client, repository) {
4
+ const [hasReadme, packageJsonContent, rootContents, workflowContents] = await Promise.all([
5
+ checkReadme(client, repository),
6
+ readPackageJson(client, repository),
7
+ readDirectory(client, repository, ''),
8
+ readDirectory(client, repository, '.github/workflows')
9
+ ]);
10
+
11
+ const hasPackageJson = packageJsonContent !== null;
12
+ const hasTests = detectTests(packageJsonContent, rootContents);
13
+ const hasCi = Array.isArray(workflowContents) && workflowContents.length > 0;
14
+
15
+ return {
16
+ hasReadme,
17
+ hasLicense: Boolean(repository.hasLicense),
18
+ hasPackageJson,
19
+ hasTests,
20
+ hasCi
21
+ };
22
+ }
23
+
24
+ async function checkReadme(client, repository) {
25
+ try {
26
+ await client.request(repoApiPath(repository, 'readme'));
27
+ return true;
28
+ } catch (error) {
29
+ if (isNotFound(error)) {
30
+ return false;
31
+ }
32
+ throw error;
33
+ }
34
+ }
35
+
36
+ async function readPackageJson(client, repository) {
37
+ try {
38
+ const response = await client.request(repoApiPath(repository, 'contents/package.json'));
39
+ if (response?.content && response?.encoding === 'base64') {
40
+ const decoded = Buffer.from(response.content, 'base64').toString('utf8');
41
+ return JSON.parse(decoded);
42
+ }
43
+ return null;
44
+ } catch (error) {
45
+ if (isNotFound(error)) {
46
+ return null;
47
+ }
48
+ throw error;
49
+ }
50
+ }
51
+
52
+ async function readDirectory(client, repository, directoryPath) {
53
+ const suffix = directoryPath.length > 0 ? `contents/${directoryPath}` : 'contents';
54
+
55
+ try {
56
+ const response = await client.request(repoApiPath(repository, suffix));
57
+ return Array.isArray(response) ? response : [];
58
+ } catch (error) {
59
+ if (isNotFound(error)) {
60
+ return [];
61
+ }
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ function detectTests(packageJson, rootContents) {
67
+ const scripts = packageJson?.scripts;
68
+ if (scripts && typeof scripts.test === 'string') {
69
+ const normalized = scripts.test.trim().toLowerCase();
70
+ if (normalized.length > 0 && !normalized.includes('no test specified')) {
71
+ return true;
72
+ }
73
+ }
74
+
75
+ const rootNames = new Set((rootContents ?? []).map((entry) => String(entry.name).toLowerCase()));
76
+ const commonTestPaths = ['test', 'tests', '__tests__', 'spec', 'specs'];
77
+
78
+ return commonTestPaths.some((name) => rootNames.has(name));
79
+ }
80
+
81
+ function repoApiPath(repository, suffix) {
82
+ return `/repos/${encodeURIComponent(repository.ownerLogin)}/${encodeURIComponent(repository.name)}/${suffix}`;
83
+ }
84
+
85
+ function isNotFound(error) {
86
+ return error instanceof GithubApiError && error.status === 404;
87
+ }
@@ -0,0 +1,58 @@
1
+ const PAGE_SIZE = 100;
2
+
3
+ export async function fetchAllRepositories(client) {
4
+ const repositories = [];
5
+
6
+ for (let page = 1; ; page += 1) {
7
+ const params = new URLSearchParams({
8
+ per_page: String(PAGE_SIZE),
9
+ page: String(page),
10
+ sort: 'full_name',
11
+ direction: 'asc',
12
+ visibility: 'all'
13
+ });
14
+
15
+ const pageItems = await client.request(`/user/repos?${params.toString()}`);
16
+
17
+ if (!Array.isArray(pageItems) || pageItems.length === 0) {
18
+ break;
19
+ }
20
+
21
+ repositories.push(...pageItems);
22
+
23
+ if (pageItems.length < PAGE_SIZE) {
24
+ break;
25
+ }
26
+ }
27
+
28
+ repositories.sort((left, right) => left.full_name.localeCompare(right.full_name));
29
+ return repositories;
30
+ }
31
+
32
+ export function normalizeRepository(repo) {
33
+ return {
34
+ id: repo.id,
35
+ nodeId: repo.node_id,
36
+ name: repo.name,
37
+ ownerLogin: repo.owner?.login ?? '',
38
+ fullName: repo.full_name,
39
+ private: repo.private,
40
+ archived: repo.archived,
41
+ fork: repo.fork,
42
+ htmlUrl: repo.html_url,
43
+ description: repo.description,
44
+ language: repo.language,
45
+ homepage: typeof repo.homepage === 'string' && repo.homepage.trim().length > 0
46
+ ? repo.homepage.trim()
47
+ : null,
48
+ stargazersCount: repo.stargazers_count,
49
+ forksCount: repo.forks_count,
50
+ openIssuesCount: repo.open_issues_count,
51
+ sizeKb: repo.size,
52
+ defaultBranch: repo.default_branch,
53
+ topics: Array.isArray(repo.topics) ? repo.topics : [],
54
+ hasLicense: Boolean(repo.license),
55
+ _updatedAt: repo.updated_at,
56
+ _pushedAt: repo.pushed_at
57
+ };
58
+ }
package/src/io/csv.js ADDED
@@ -0,0 +1,61 @@
1
+ import path from 'node:path';
2
+ import { writeTextFile } from './files.js';
3
+
4
+ export async function writeInventoryCsv(outputDir, items) {
5
+ const filePath = path.join(outputDir, 'inventory.csv');
6
+ const header = [
7
+ 'id',
8
+ 'fullName',
9
+ 'type',
10
+ 'private',
11
+ 'archived',
12
+ 'language',
13
+ 'stargazersCount',
14
+ 'sizeKb',
15
+ 'activity',
16
+ 'maturity',
17
+ 'score',
18
+ 'state',
19
+ 'strategy',
20
+ 'effort',
21
+ 'value',
22
+ 'nextAction'
23
+ ];
24
+
25
+ const rows = [header.join(',')];
26
+
27
+ for (const item of items) {
28
+ const row = [
29
+ item.id,
30
+ item.fullName,
31
+ item.type,
32
+ item.private,
33
+ item.archived,
34
+ item.language ?? '',
35
+ item.stargazersCount ?? 0,
36
+ item.sizeKb ?? 0,
37
+ item.activity ?? '',
38
+ item.maturity ?? '',
39
+ item.score ?? 0,
40
+ item.state ?? '',
41
+ item.strategy ?? '',
42
+ item.effort ?? '',
43
+ item.value ?? '',
44
+ item.nextAction ?? ''
45
+ ].map(csvEscape);
46
+
47
+ rows.push(row.join(','));
48
+ }
49
+
50
+ await writeTextFile(filePath, `${rows.join('\n')}\n`);
51
+ return filePath;
52
+ }
53
+
54
+ function csvEscape(value) {
55
+ const stringValue = String(value ?? '');
56
+ if (!/[",\n]/.test(stringValue)) {
57
+ return stringValue;
58
+ }
59
+
60
+ return `"${stringValue.replaceAll('"', '""')}"`;
61
+ }
@@ -0,0 +1,39 @@
1
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export async function ensureDirectory(dirPath) {
5
+ await mkdir(dirPath, { recursive: true });
6
+ }
7
+
8
+ export async function writeJsonFile(filePath, value) {
9
+ await ensureDirectory(path.dirname(filePath));
10
+ await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
11
+ }
12
+
13
+ export async function writeTextFile(filePath, content) {
14
+ await ensureDirectory(path.dirname(filePath));
15
+ await writeFile(filePath, content, 'utf8');
16
+ }
17
+
18
+ export async function readJsonFile(filePath) {
19
+ const content = await readFile(filePath, 'utf8');
20
+ return JSON.parse(content);
21
+ }
22
+
23
+ export async function readJsonFileIfExists(filePath) {
24
+ const exists = await fileExists(filePath);
25
+ if (!exists) {
26
+ return null;
27
+ }
28
+
29
+ return readJsonFile(filePath);
30
+ }
31
+
32
+ export async function fileExists(filePath) {
33
+ try {
34
+ await access(filePath);
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }