codebase-onboarder 0.5.0 → 0.6.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/README.md +23 -2
- package/cli/explorer/advanced.js +457 -0
- package/cli/explorer/app.js +107 -11
- package/cli/explorer/commands.js +186 -14
- package/cli/explorer/github.js +75 -0
- package/cli/explorer/graphs.js +178 -0
- package/cli/explorer/session.js +95 -5
- package/cli/explorer/views.js +216 -115
- package/cli/explorer/wrap.js +49 -0
- package/cli/main.js +49 -5
- package/cli/ui.js +31 -2
- package/package.json +1 -1
- package/public/js/about.js +20 -7
- package/server/gitRemote.js +61 -0
- package/server/layout.js +41 -14
- package/shared/analyzer/github.js +81 -0
package/cli/main.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
runConfig, runConfigKey, runConfigReset, runDoctor, runTunnel, runHttps,
|
|
13
13
|
} from './commands.js';
|
|
14
14
|
import { runExplore } from './explorer/app.js';
|
|
15
|
+
import { setColorEnabled } from './ui.js';
|
|
15
16
|
|
|
16
17
|
const PACKAGE = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
17
18
|
|
|
@@ -20,7 +21,7 @@ const HELP = `
|
|
|
20
21
|
|
|
21
22
|
Usage
|
|
22
23
|
onboarder Explore a codebase here, interactively
|
|
23
|
-
onboarder explore [folder]
|
|
24
|
+
onboarder explore [folder|url] The same, pointed somewhere else (alias: tui)
|
|
24
25
|
onboarder start Start the web UI in the foreground (Ctrl-C stops it)
|
|
25
26
|
onboarder start background Start detached — keeps running after you close the terminal
|
|
26
27
|
onboarder start startup Run automatically at login [install|remove|status]
|
|
@@ -37,10 +38,16 @@ const HELP = `
|
|
|
37
38
|
In the explorer
|
|
38
39
|
map tour explain what this is, where to start, and why
|
|
39
40
|
tree find show deps browse it, search it, read it, trace it
|
|
41
|
+
graph blast symbols the dependency tree, the blast radius, the outline
|
|
40
42
|
health hubs layers patterns the analysis, in the same words the site uses
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
coupling clusters risks the heat grid, the module groups, what is wrong
|
|
44
|
+
log hotspots blame git history, hot files, who wrote a line
|
|
45
|
+
diagram docs Mermaid source, and prose you can write out
|
|
46
|
+
cd rescan web switch repo (or clone a URL), reload, open the web UI
|
|
47
|
+
github what GitHub says about this repo — stars, issues, license
|
|
48
|
+
!<command> run a shell command without leaving
|
|
43
49
|
help exit everything, and the way out
|
|
50
|
+
Tab completes commands, then file paths
|
|
44
51
|
|
|
45
52
|
Setup flags (interactive wizard skips what they answer)
|
|
46
53
|
--mode local|self-hosted --host <addr> --port <n> --domain <name>
|
|
@@ -68,6 +75,7 @@ const HELP = `
|
|
|
68
75
|
Examples
|
|
69
76
|
onboarder # explore the repo you are standing in
|
|
70
77
|
onboarder explore ~/code/my-app # explore somewhere else
|
|
78
|
+
onboarder explore https://github.com/expressjs/express # clone one and read it
|
|
71
79
|
onboarder start background # leave the web UI running, close the terminal
|
|
72
80
|
onboarder logs -f # watch what it is doing
|
|
73
81
|
onboarder start startup install # also start it every time you log in
|
|
@@ -85,7 +93,7 @@ const OPTIONS = {
|
|
|
85
93
|
reveal: { type: 'boolean' },
|
|
86
94
|
verbose: { type: 'boolean' },
|
|
87
95
|
start: { type: 'boolean' },
|
|
88
|
-
color: { type: 'boolean' }, //
|
|
96
|
+
color: { type: 'boolean' }, // `--no-color` is lifted out by extractNegatedFlags; --color forces it on
|
|
89
97
|
config: { type: 'string' },
|
|
90
98
|
mode: { type: 'string' },
|
|
91
99
|
host: { type: 'string' },
|
|
@@ -117,10 +125,32 @@ function normalizeFlags(values) {
|
|
|
117
125
|
return out;
|
|
118
126
|
}
|
|
119
127
|
|
|
128
|
+
// `parseArgs` cannot express a negated boolean. `--color=false` is rejected
|
|
129
|
+
// outright, `--color false` sets `color: true` and leaves `false` behind as a
|
|
130
|
+
// positional, and `--no-color` is an unknown option — so the documented flag
|
|
131
|
+
// had never worked, for any command, and `--no-color status` would try to run a
|
|
132
|
+
// command called `no-color`.
|
|
133
|
+
//
|
|
134
|
+
// There is no parser-level spelling for "this boolean is false", so the flag is
|
|
135
|
+
// lifted out of argv before parsing and applied to the parsed flags after. That
|
|
136
|
+
// keeps `parseArgs` strict about genuinely unknown options (which is the point
|
|
137
|
+
// of a tool that writes a config file) while making the one negation the CLI
|
|
138
|
+
// documents actually work.
|
|
139
|
+
function extractNegatedFlags(argv) {
|
|
140
|
+
const rest = [];
|
|
141
|
+
let noColor = false;
|
|
142
|
+
for (const arg of argv) {
|
|
143
|
+
if (arg === '--no-color') noColor = true;
|
|
144
|
+
else rest.push(arg);
|
|
145
|
+
}
|
|
146
|
+
return { argv: rest, noColor };
|
|
147
|
+
}
|
|
148
|
+
|
|
120
149
|
export async function main(argv = process.argv.slice(2)) {
|
|
150
|
+
const { argv: argsIn, noColor } = extractNegatedFlags(argv);
|
|
121
151
|
let parsed;
|
|
122
152
|
try {
|
|
123
|
-
parsed = parseArgs({ args:
|
|
153
|
+
parsed = parseArgs({ args: argsIn, options: OPTIONS, allowPositionals: true });
|
|
124
154
|
} catch (e) {
|
|
125
155
|
console.error(' ' + e.message + '\n' + HELP);
|
|
126
156
|
return 2;
|
|
@@ -128,6 +158,20 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
128
158
|
const { values, positionals } = parsed;
|
|
129
159
|
const flags = normalizeFlags(values);
|
|
130
160
|
|
|
161
|
+
// Color is decided here, after the flags exist and before anything is
|
|
162
|
+
// painted. An explicit `--no-color` wins over `FORCE_COLOR`: a person who
|
|
163
|
+
// typed the flag meant it, and a stray `FORCE_COLOR` inherited from a CI
|
|
164
|
+
// profile should not override the thing they just asked for.
|
|
165
|
+
if (noColor) {
|
|
166
|
+
flags.color = false;
|
|
167
|
+
setColorEnabled(false);
|
|
168
|
+
} else if (flags.color === true) {
|
|
169
|
+
// `--color` was accepted by the parser and then ignored, which is the same
|
|
170
|
+
// lie in the other direction. It is the documented way to keep ANSI in a
|
|
171
|
+
// captured log, so it has to actually turn color on.
|
|
172
|
+
setColorEnabled(true);
|
|
173
|
+
}
|
|
174
|
+
|
|
131
175
|
if (flags.help) { console.log(HELP); return 0; }
|
|
132
176
|
if (flags.version) { console.log(PACKAGE.version); return 0; }
|
|
133
177
|
|
package/cli/ui.js
CHANGED
|
@@ -4,7 +4,36 @@
|
|
|
4
4
|
// all strip styling in one place. Nothing here is load-bearing — every message
|
|
5
5
|
// has to read fine as plain text, because that is how logs and CI see it.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// Color resolution, in strict precedence order:
|
|
8
|
+
//
|
|
9
|
+
// 1. An explicit `--no-color` / `NO_COLOR` → off. A person who asked for plain
|
|
10
|
+
// text gets plain text, whatever the environment says.
|
|
11
|
+
// 2. An explicit `--color` / `FORCE_COLOR` → on. This is the documented way
|
|
12
|
+
// to keep ANSI in a captured log or a pipe, so it has to beat the TTY sniff.
|
|
13
|
+
// 3. Otherwise, a TTY → on.
|
|
14
|
+
// 4. Otherwise → off.
|
|
15
|
+
//
|
|
16
|
+
// Every step reads live rather than caching at import, because `main` parses
|
|
17
|
+
// flags *after* the module graph has loaded. The old `const supportsColor = ...`
|
|
18
|
+
// decided once, at import, which is before `--no-color` was knowable — so the
|
|
19
|
+
// documented flags were silently ignored everywhere.
|
|
20
|
+
let forced = null; // null = undecided, true = force on, false = force off
|
|
21
|
+
|
|
22
|
+
export function setColorEnabled(on) {
|
|
23
|
+
forced = on === undefined ? null : Boolean(on);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function colorEnabled() {
|
|
27
|
+
if (forced === false) return false;
|
|
28
|
+
if (forced === true) return true;
|
|
29
|
+
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false;
|
|
30
|
+
if (process.env.FORCE_COLOR) return true;
|
|
31
|
+
return Boolean(process.stdout.isTTY);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function supportsColor() {
|
|
35
|
+
return colorEnabled();
|
|
36
|
+
}
|
|
8
37
|
|
|
9
38
|
const CODES = {
|
|
10
39
|
reset: 0, bold: 1, dim: 2, italic: 3,
|
|
@@ -12,7 +41,7 @@ const CODES = {
|
|
|
12
41
|
};
|
|
13
42
|
|
|
14
43
|
export function paint(text, ...styles) {
|
|
15
|
-
if (!
|
|
44
|
+
if (!colorEnabled() || !styles.length) return String(text);
|
|
16
45
|
const open = styles.map((s) => `\x1b[${CODES[s]}m`).join('');
|
|
17
46
|
return `${open}${text}\x1b[0m`;
|
|
18
47
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codebase-onboarder",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Drop a path. Get a map. A zero-dependency codebase visualizer with a CLI onboarding wizard, a web UI, and an optional key-gated self-hosted mode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/public/js/about.js
CHANGED
|
@@ -239,11 +239,18 @@ export function lineageCoverage(scan) {
|
|
|
239
239
|
return out.join('\n');
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
242
|
+
// The GitHub URL parsing and the reading of the API payload also exist in
|
|
243
|
+
// `/shared/analyzer/github.js`, where the terminal reads them. This copy is not
|
|
244
|
+
// an oversight and cannot simply be deleted: this module is loaded by Node tests
|
|
245
|
+
// (`tests/about.test.js`), and a `/shared/…` specifier does not resolve in Node,
|
|
246
|
+
// so importing the shared version here would make this whole file unloadable
|
|
247
|
+
// outside a browser. The duplication is therefore pinned by a test —
|
|
248
|
+
// "the two GitHub URL parsers agree" — rather than left to drift, which is what
|
|
249
|
+
// two unconnected copies of this regex had already done: the browser counted
|
|
250
|
+
// `watchers_count` (a legacy alias for the *stargazer* count) and called it
|
|
251
|
+
// "watching", printing the star number twice.
|
|
245
252
|
export function githubRepoPath(url) {
|
|
246
|
-
const m = String(url).match(/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/);
|
|
253
|
+
const m = String(url ?? '').match(/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/);
|
|
247
254
|
return m ? m[1] : null;
|
|
248
255
|
}
|
|
249
256
|
|
|
@@ -262,21 +269,27 @@ async function loadRemoteInfo(repoPath) {
|
|
|
262
269
|
: `GitHub answered ${res.status}.`);
|
|
263
270
|
}
|
|
264
271
|
const r = await res.json();
|
|
272
|
+
// `subscribers_count`, not `watchers_count`. The API carries both and
|
|
273
|
+
// `watchers_count` is a legacy alias for the *stargazer* count, so reading
|
|
274
|
+
// it printed the star number a second time and called it "watching". The
|
|
275
|
+
// real watcher count is the subscribers number.
|
|
276
|
+
const watching = Number.isFinite(r.subscribers_count) ? r.subscribers_count : 0;
|
|
277
|
+
const license = r.license?.spdx_id && r.license.spdx_id !== 'NOASSERTION' ? r.license.spdx_id : '';
|
|
265
278
|
box.innerHTML = `
|
|
266
279
|
<div class="about-numbers">
|
|
267
280
|
${aboutNum(r.stargazers_count ?? 0, 'stars')}
|
|
268
281
|
${aboutNum(r.forks_count ?? 0, 'forks')}
|
|
269
|
-
${aboutNum(
|
|
282
|
+
${aboutNum(watching, 'watching')}
|
|
270
283
|
${aboutNum(r.open_issues_count ?? 0, 'open issues')}
|
|
271
284
|
</div>
|
|
272
285
|
${r.description ? `<p class="about-desc">${escapeHtml(r.description)}</p>` : ''}
|
|
273
286
|
<ul class="about-lineage">
|
|
274
|
-
${
|
|
287
|
+
${license ? `<li>License: ${escapeHtml(license)}</li>` : ''}
|
|
275
288
|
<li>Created ${aboutDate(r.created_at)} · last push ${aboutDate(r.pushed_at)}.</li>
|
|
276
289
|
${r.default_branch ? `<li>Default branch: <code>${escapeHtml(r.default_branch)}</code></li>` : ''}
|
|
277
290
|
${r.topics?.length ? `<li>Topics: ${r.topics.map((t) => `<span class="stat-chip">${escapeHtml(t)}</span>`).join(' ')}</li>` : ''}
|
|
278
291
|
${r.homepage ? `<li><a href="${escapeHtml(r.homepage)}" target="_blank" rel="noopener noreferrer">${escapeHtml(r.homepage)}</a></li>` : ''}
|
|
279
|
-
<li class="doc-readme-none">
|
|
292
|
+
<li class="doc-readme-none">These are GitHub's numbers, not this repository's — <code>health</code> and <code>risks</code> are about the code.</li>
|
|
280
293
|
</ul>`;
|
|
281
294
|
} catch (err) {
|
|
282
295
|
box.innerHTML = `<span class="doc-readme-none">${escapeHtml(err.message)}</span>`;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The URL a local checkout came from — `git remote get-url origin`.
|
|
2
|
+
//
|
|
3
|
+
// The web app never needs this: it only shows remote facts for a repo it cloned
|
|
4
|
+
// itself, where it already knows the URL it was given. A terminal session is
|
|
5
|
+
// usually started *inside* somebody's checkout, so `github` there has no URL to
|
|
6
|
+
// work from and has to ask git. That is the one place this surface is a strict
|
|
7
|
+
// improvement on the site rather than a copy of it.
|
|
8
|
+
//
|
|
9
|
+
// Same discipline as `gitHistory.js`: git is spawned with an argument array,
|
|
10
|
+
// never through a shell, and a repo with no remote is an expected outcome rather
|
|
11
|
+
// than an error — a plain folder, a tarball, and a checkout with the remote
|
|
12
|
+
// removed are all normal.
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
|
|
16
|
+
const TIMEOUT_MS = 10_000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The `origin` remote of a checkout, or null with a reason.
|
|
20
|
+
*
|
|
21
|
+
* `origin` specifically, not "the first remote": origin is the one that means
|
|
22
|
+
* "where this came from". A fork's `upstream` is a different repository, and
|
|
23
|
+
* reporting that repo's stars for a fork is a confident wrong answer.
|
|
24
|
+
*/
|
|
25
|
+
export function gitRemoteOrigin(root) {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
let child;
|
|
28
|
+
try {
|
|
29
|
+
child = spawn('git', ['-C', root, 'remote', 'get-url', 'origin'], {
|
|
30
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
31
|
+
});
|
|
32
|
+
} catch (err) {
|
|
33
|
+
resolve({ ok: false, reason: 'Could not run git: ' + err.message });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let out = '';
|
|
38
|
+
let stderr = '';
|
|
39
|
+
const cap = (buf, max) => (buf.length > max ? buf.slice(-max) : buf);
|
|
40
|
+
child.stdout.on('data', (c) => { out = cap(out + c, 2000); });
|
|
41
|
+
child.stderr.on('data', (c) => { stderr = cap(stderr + c, 2000); });
|
|
42
|
+
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
child.kill('SIGKILL');
|
|
45
|
+
resolve({ ok: false, reason: 'git took too long to say where this came from.' });
|
|
46
|
+
}, TIMEOUT_MS);
|
|
47
|
+
|
|
48
|
+
// `once`, and the promise is only ever settled once, so a timeout that
|
|
49
|
+
// races a late `close` cannot resolve an already-resolved promise.
|
|
50
|
+
const settle = (value) => { clearTimeout(timer); resolve(value); };
|
|
51
|
+
|
|
52
|
+
child.on('error', (err) => settle({ ok: false, reason: 'Could not run git: ' + err.message }));
|
|
53
|
+
child.on('close', (code) => {
|
|
54
|
+
const url = out.trim();
|
|
55
|
+
if (code === 0 && url) settle({ ok: true, url });
|
|
56
|
+
else if (/not a git repository/i.test(stderr)) settle({ ok: false, reason: 'This folder is not a git checkout, so it has no remote.' });
|
|
57
|
+
else if (code === 0) settle({ ok: false, reason: 'This checkout has no origin remote.' });
|
|
58
|
+
else settle({ ok: false, reason: 'git would not say: ' + (stderr.trim().split('\n').pop() || ('exited ' + code)) });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
package/server/layout.js
CHANGED
|
@@ -44,26 +44,53 @@ export function fit(text, max, { tail = true } = {}) {
|
|
|
44
44
|
// 'frame'. The default is identity, which is what the server banner and the
|
|
45
45
|
// `--no-color` path want.
|
|
46
46
|
export function panel(title, rows, { indent = ' ', columns, paint = (t) => String(t) } = {}) {
|
|
47
|
-
// The hard ceiling is the terminal
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
//
|
|
54
|
-
const
|
|
47
|
+
// The hard ceiling is the terminal, and it is the *only* ceiling. This used to
|
|
48
|
+
// clamp to a 24-column floor "to guard against a nonsensical zero", but a
|
|
49
|
+
// floor above the available width is precisely the overflow this function
|
|
50
|
+
// exists to prevent: in a 10-column terminal it produced 26-column lines and
|
|
51
|
+
// wrapped the border. A terminal can be narrower than a box; below the point
|
|
52
|
+
// where a bordered box is readable at all, the frame degrades to a plain
|
|
53
|
+
// aligned list, which is still honest output instead of a lie about width.
|
|
54
|
+
const total = Math.max(1, Math.floor(columns || termWidth()) - indent.length);
|
|
55
|
+
|
|
56
|
+
// A border costs 2 cells on each side; the label column costs `labelWidth`,
|
|
57
|
+
// then a 2-cell gap, then a content indent. Below what is left there is no
|
|
58
|
+
// room for a readable value, so the border is dropped entirely rather than
|
|
59
|
+
// drawn wider than the terminal. The content indent goes too: at a 3-column
|
|
60
|
+
// terminal, indent(2) + content(2) + one character is already 5, and the
|
|
61
|
+
// indent is decoration, not information.
|
|
62
|
+
const borderWorthIt = total >= 16;
|
|
63
|
+
const bodyIndent = total >= 8 ? 2 : 0;
|
|
64
|
+
const labelWidth = borderWorthIt ? Math.min(12, Math.max(...rows.map((r) => width(r.label ?? '')), 0)) : 0;
|
|
65
|
+
const gap = labelWidth ? 2 : 0;
|
|
66
|
+
const valueRoom = Math.max(1, total - (borderWorthIt ? 2 : 0) - labelWidth - gap - bodyIndent);
|
|
67
|
+
|
|
55
68
|
const body = rows.map((r) => (r.hint
|
|
56
|
-
? '
|
|
57
|
-
:
|
|
69
|
+
? ' '.repeat(labelWidth + gap) + paint(fit(r.hint, valueRoom), 'hint')
|
|
70
|
+
: (labelWidth ? paint(fit(r.label ?? '', labelWidth).padEnd(labelWidth), 'label') + ' '.repeat(gap) : '')
|
|
71
|
+
+ fit(r.value ?? '', valueRoom)));
|
|
72
|
+
|
|
73
|
+
if (!borderWorthIt) {
|
|
74
|
+
// No room for a frame. Still fitted, still labeled, just not boxed. The
|
|
75
|
+
// indent goes first — it is decoration, and at a 1–2 column terminal the
|
|
76
|
+
// 2-space indent plus a single content character is already 3, which wraps.
|
|
77
|
+
const lead = ' '.repeat(Math.max(0, Math.min(indent.length, total - 1)));
|
|
78
|
+
return [
|
|
79
|
+
paint(fit(title, total), 'title'),
|
|
80
|
+
...body.map((line) => lead + ' '.repeat(bodyIndent) + fit(line, Math.max(1, total - lead.length - bodyIndent))),
|
|
81
|
+
].join('\n');
|
|
82
|
+
}
|
|
58
83
|
|
|
59
84
|
// Frame width = whatever the body needs, capped to what the terminal has.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
85
|
+
// The 2-cell content indent belongs to the body, so it counts toward the
|
|
86
|
+
// width the border has to enclose.
|
|
87
|
+
const wanted = Math.max(...body.map((line) => width(' ' + line)), 1);
|
|
88
|
+
const inner = Math.min(total - 2, Math.max(1, wanted));
|
|
89
|
+
const shownTitle = fit(title, Math.max(0, inner - 4));
|
|
63
90
|
const top = paint('┌─ ', 'frame') + paint(shownTitle, 'title')
|
|
64
91
|
+ ' ' + paint('─'.repeat(Math.max(0, inner - shownTitle.length - 3)) + '┐', 'frame');
|
|
65
92
|
const bottom = paint('└' + '─'.repeat(inner) + '┘', 'frame');
|
|
66
|
-
return [top, ...body
|
|
93
|
+
return [indent + top, ...body.map((line) => indent + ' ' + line), indent + bottom].join('\n');
|
|
67
94
|
}
|
|
68
95
|
|
|
69
96
|
// A one-line note rendered in the panel's muted voice.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Reading a GitHub repository's public facts — the part the browser and the
|
|
2
|
+
// terminal both need, and the part that was quietly wrong in one of them.
|
|
3
|
+
//
|
|
4
|
+
// This module is pure: it takes an already-parsed API payload and returns plain
|
|
5
|
+
// data. The `fetch` deliberately stays on either side of it, because the two
|
|
6
|
+
// callers can do things the other cannot — the browser must not hold a token,
|
|
7
|
+
// and the terminal can. `shared/` may not know which runtime it is in, so the
|
|
8
|
+
// network call is not here; only the reading of the answer is.
|
|
9
|
+
//
|
|
10
|
+
// One implementation, because the previous arrangement had the browser with its
|
|
11
|
+
// own copy of the URL regex and its own idea of which counter means "watching",
|
|
12
|
+
// and the two drifted apart without anything noticing.
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The `owner/repo` a git URL points at, or null if it is not a GitHub URL.
|
|
16
|
+
*
|
|
17
|
+
* Both URL shapes git accepts: `https://github.com/owner/repo(.git)` and
|
|
18
|
+
* `git@github.com:owner/repo(.git)`. Anything else is not GitHub, and gets no
|
|
19
|
+
* remote lookup — a GitLab or Bitbucket clone is a real outcome, not an error.
|
|
20
|
+
*/
|
|
21
|
+
export function githubRepoPath(url) {
|
|
22
|
+
const m = String(url ?? '').match(/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/);
|
|
23
|
+
return m ? m[1] : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The public facts worth showing, read out of `GET /repos/{owner}/{repo}`.
|
|
28
|
+
*
|
|
29
|
+
* Returns null for anything that is not an object, so a caller can tell "no
|
|
30
|
+
* facts" from "all zeroes" without a try/catch around every field.
|
|
31
|
+
*/
|
|
32
|
+
export function remoteFacts(json) {
|
|
33
|
+
if (!json || typeof json !== 'object') return null;
|
|
34
|
+
const count = (v) => (Number.isFinite(v) && v >= 0 ? Math.trunc(v) : 0);
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
stars: count(json.stargazers_count),
|
|
38
|
+
forks: count(json.forks_count),
|
|
39
|
+
// `subscribers_count`, not `watchers_count`. The API has carried both for
|
|
40
|
+
// years and `watchers_count` is a legacy alias for the *stargazer* count —
|
|
41
|
+
// so labelling it "watching" printed the star number twice and called one
|
|
42
|
+
// of them watchers. The real watcher count is the subscribers number.
|
|
43
|
+
watching: count(json.subscribers_count),
|
|
44
|
+
issues: count(json.open_issues_count),
|
|
45
|
+
description: typeof json.description === 'string' ? json.description.trim() : '',
|
|
46
|
+
// "NOASSERTION" is GitHub's way of saying it found a license file it could
|
|
47
|
+
// not identify, which is not a license anyone can comply with.
|
|
48
|
+
license: json.license?.spdx_id && json.license.spdx_id !== 'NOASSERTION'
|
|
49
|
+
? json.license.spdx_id
|
|
50
|
+
: '',
|
|
51
|
+
created: typeof json.created_at === 'string' ? json.created_at : '',
|
|
52
|
+
pushed: typeof json.pushed_at === 'string' ? json.pushed_at : '',
|
|
53
|
+
branch: typeof json.default_branch === 'string' ? json.default_branch : '',
|
|
54
|
+
topics: Array.isArray(json.topics)
|
|
55
|
+
? json.topics.filter((t) => typeof t === 'string' && t).slice(0, 12)
|
|
56
|
+
: [],
|
|
57
|
+
homepage: typeof json.homepage === 'string' ? json.homepage.trim() : '',
|
|
58
|
+
archived: Boolean(json.archived),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Why a remote lookup did not produce facts, in the words a person can act on.
|
|
64
|
+
*
|
|
65
|
+
* 403, 404 and 429 are separated because they mean opposite things — one is
|
|
66
|
+
* "ask less often, or authenticate", one is "private, or gone, or never there",
|
|
67
|
+
* and one is "slow down" — and collapsing them into "GitHub said no" helps
|
|
68
|
+
* nobody diagnose anything.
|
|
69
|
+
*/
|
|
70
|
+
export function remoteError(status) {
|
|
71
|
+
if (status === 403) {
|
|
72
|
+
return 'GitHub rate-limited the ask — the unauthenticated allowance is 60 an hour.';
|
|
73
|
+
}
|
|
74
|
+
if (status === 404) {
|
|
75
|
+
return 'GitHub has no such repository, or it is private.';
|
|
76
|
+
}
|
|
77
|
+
if (status === 429) {
|
|
78
|
+
return 'GitHub is throttling this machine — try again in a minute.';
|
|
79
|
+
}
|
|
80
|
+
return 'GitHub answered ' + status + '.';
|
|
81
|
+
}
|