Skip to content

Commit 024915a

Browse files
committed
feat(create): propose the VCS host and CI provider from the fleet too
Same proposal for the last two one-of-many choices in `service create`, which until now defaulted to the constants 'github' and 'github-actions' regardless of what the fleet around them actually used. These two leave a different kind of trace, so a probe member now accepts any of three: a dependency (the ORM and tracing addons), a file or directory (a CI provider's config — .github/workflows, .circleci/config.yml, .travis.yml), or a substring of a remote URL in .git/config (the VCS host). The git config is read directly rather than by shelling out to git: this runs once per service before a prompt, and a process per service is the cost the cache exists to avoid. It is read once per service rather than once per member, since three of the four probes want it. The VCS host is matched on host, not domain — `gitlab.example.com` is still GitLab, and both GitLab and Bitbucket are commonly self-hosted. A host that names none of them, GitHub Enterprise on a custom domain say, leaves no evidence, and no evidence is reported as no evidence rather than guessed at. Two things these differ from the catalog groups in: They are provider ids, not catalog ids, so they must NOT reach the packages prompt — validateSelection would reject 'github' as an unknown package, and rightly. A probe now declares its kind, and fleetDefaults() returns catalog members only. And the proposal applies non-interactively as well. A package is an added capability, so a --yes run stays out of it; a VCS host is not — one is picked either way, so following the fleet beats following a constant even with no prompt. The CI proposal is filtered through the existing VCS compatibility check before being offered, since Travis with a cloud registry is rejected further down — proposing an incompatible provider would only move the error. A choice against the scan is recorded for these too, but only when made HERE: by flag, or by answering the prompt. A configured provider is already a standing decision and copying it into a per-fleet override would spread it silently. 23 fleet tests. Verified end to end against a sandboxed fleet on GitLab with CircleCI, Sequelize and Datadog: all four proposals correct from one scan, and the VCS prompt opens on GitLab with the reason shown.
1 parent 8d6ede9 commit 024915a

3 files changed

Lines changed: 351 additions & 29 deletions

File tree

src/catalog/fleet.ts

Lines changed: 150 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,26 +33,49 @@ import { join } from 'path';
3333
import { VAR_HOME, resolve } from '../../lib/index.js';
3434

3535
/**
36-
* An exclusive catalog group whose members can be recognised in a service's
37-
* dependencies, so the fleet can answer the question instead of the user.
36+
* How one member of a group can be recognised in an existing service.
3837
*
39-
* Add a group here and the prompt starts proposing for it; nothing else needs to
40-
* change. A group whose members leave no dependency trace — anything the
41-
* scaffolder only injects code for — cannot be probed this way and belongs
42-
* absent from this list rather than half-supported.
38+
* Any of the three is enough. They exist because the four groups leave three
39+
* different kinds of trace: an addon shows up as a dependency, a CI provider as
40+
* the config file it reads, and a VCS host only in the git remote.
41+
*/
42+
interface ProbeMember {
43+
/** Catalog id, provider id — whatever the prompt for this group selects. */
44+
id: string;
45+
/** A dependency in the service's package.json. */
46+
dep?: string;
47+
/** A path inside the service, file or directory. */
48+
files?: string[];
49+
/** A substring of a remote URL in the service's .git/config. */
50+
remote?: string;
51+
}
52+
53+
/**
54+
* A one-of-many choice the fleet can answer instead of the user.
55+
*
56+
* Add a probe and the corresponding prompt starts proposing; nothing else needs
57+
* to change. A choice that leaves no trace in an existing service cannot be
58+
* probed this way and belongs absent rather than half-supported.
4359
*/
4460
interface GroupProbe {
45-
/** Catalog group id. */
61+
/** Group id: a catalog group for `catalog` probes, else a name for the note. */
4662
group: string;
4763
/** Human name for the group, for the note. */
4864
label: string;
49-
/** Catalog member id paired with the dependency that gives it away. */
50-
members: { id: string; dep: string }[];
65+
/**
66+
* `catalog` members are catalog package ids and reach the packages prompt
67+
* through {@link fleetDefaults}; `setting` members are provider ids chosen by
68+
* their own prompt and must NOT be handed to the catalog, which would reject
69+
* them as unknown packages.
70+
*/
71+
kind: 'catalog' | 'setting';
72+
members: ProbeMember[];
5173
/**
5274
* The member to propose when the fleet uses several, when one of them is
5375
* recommended. Without it a split fleet falls back to its own majority,
5476
* which is the honest answer where the project has no preference — the two
55-
* tracing backends are a vendor choice, not a better and a worse.
77+
* tracing backends are a vendor choice, not a better and a worse, and so is
78+
* one git host over another.
5679
*/
5780
recommended?: string;
5881
}
@@ -61,6 +84,7 @@ const PROBES: GroupProbe[] = [
6184
{
6285
group: 'orm',
6386
label: 'ORM',
87+
kind: 'catalog',
6488
members: [
6589
{ id: 'sequelize', dep: '@imqueue/sequelize' },
6690
{ id: 'pg-prisma', dep: '@imqueue/pg-prisma' },
@@ -70,6 +94,7 @@ const PROBES: GroupProbe[] = [
7094
{
7195
group: 'tracing',
7296
label: 'tracing',
97+
kind: 'catalog',
7398
members: [
7499
{
75100
id: 'opentelemetry',
@@ -78,6 +103,30 @@ const PROBES: GroupProbe[] = [
78103
{ id: 'dd-trace', dep: '@imqueue/dd-trace' },
79104
],
80105
},
106+
{
107+
group: 'vcs',
108+
label: 'VCS host',
109+
kind: 'setting',
110+
members: [
111+
{ id: 'github', remote: 'github.com' },
112+
// Host rather than domain: GitLab and Bitbucket are both commonly
113+
// self-hosted, and `gitlab.example.com` is still GitLab. A host that
114+
// names none of them — GitHub Enterprise on a custom domain, say —
115+
// leaves no evidence, which is reported as no evidence.
116+
{ id: 'gitlab', remote: 'gitlab' },
117+
{ id: 'bitbucket', remote: 'bitbucket' },
118+
],
119+
},
120+
{
121+
group: 'ci',
122+
label: 'CI provider',
123+
kind: 'setting',
124+
members: [
125+
{ id: 'github-actions', files: ['.github/workflows'] },
126+
{ id: 'circleci', files: ['.circleci/config.yml'] },
127+
{ id: 'travis', files: ['.travis.yml', '.travis.yaml'] },
128+
],
129+
},
81130
];
82131

83132
/** What the fleet says about one exclusive group. */
@@ -230,6 +279,51 @@ function dependenciesOf(dir: string): Set<string> {
230279
}
231280
}
232281

282+
/**
283+
* The remote URLs a service's git config names.
284+
*
285+
* @remarks
286+
* Read straight out of `.git/config` rather than by shelling out to git: this
287+
* runs once per service before a prompt, and a process per service is exactly
288+
* the cost the cache exists to avoid.
289+
*
290+
* @param {string} dir
291+
* @return {string}
292+
*/
293+
function gitRemotes(dir: string): string {
294+
try {
295+
return readFileSync(join(dir, '.git', 'config'), 'utf8');
296+
} catch {
297+
return '';
298+
}
299+
}
300+
301+
/**
302+
* Whether one service shows evidence of a member.
303+
*
304+
* @param {string} dir - the service directory
305+
* @param {Set<string>} deps - its declared dependencies
306+
* @param {string} remotes - its .git/config contents
307+
* @param {ProbeMember} member
308+
* @return {boolean}
309+
*/
310+
function usesMember(
311+
dir: string,
312+
deps: Set<string>,
313+
remotes: string,
314+
member: ProbeMember,
315+
): boolean {
316+
if (member.dep && deps.has(member.dep)) {
317+
return true;
318+
}
319+
320+
if (member.files?.some(file => existsSync(join(dir, file)))) {
321+
return true;
322+
}
323+
324+
return !!member.remote && remotes.includes(member.remote);
325+
}
326+
233327
/**
234328
* Counts, for every probed group, how many services use each of its members.
235329
*
@@ -248,17 +342,22 @@ function scan(root: string): FleetRecord {
248342
}
249343

250344
for (const dir of dirs) {
251-
const deps = dependenciesOf(join(root, dir));
345+
const path = join(root, dir);
346+
const deps = dependenciesOf(path);
252347

253348
if (!deps.has(RPC)) {
254349
continue;
255350
}
256351

257352
services++;
258353

354+
// Read once per service, not once per member: three of the four probes
355+
// want it and re-reading would make the scan four times the IO.
356+
const remotes = gitRemotes(path);
357+
259358
for (const probe of PROBES) {
260359
for (const member of probe.members) {
261-
if (deps.has(member.dep)) {
360+
if (usesMember(path, deps, remotes, member)) {
262361
groups[probe.group][member.id]++;
263362
}
264363
}
@@ -383,17 +482,39 @@ export function analyseFleet(root: string): FleetAnalysis {
383482
}
384483

385484
/**
386-
* The catalog ids to preselect, across every probed group.
485+
* The CATALOG ids to preselect.
486+
*
487+
* @remarks
488+
* Catalog groups only. A provider id like `github` would be rejected by
489+
* `validateSelection` as an unknown package, and rightly — it is not one.
387490
*
388491
* @param {FleetAnalysis} analysis
389492
* @return {string[]}
390493
*/
391494
export function fleetDefaults(analysis: FleetAnalysis): string[] {
392-
return Object.values(analysis.groups)
393-
.map(group => group.propose)
495+
return PROBES.filter(probe => probe.kind === 'catalog')
496+
.map(probe => analysis.groups[probe.group]?.propose)
394497
.filter((id): id is string => !!id);
395498
}
396499

500+
/**
501+
* What the fleet proposes for one group, or `null` when it says nothing.
502+
*
503+
* @remarks
504+
* For the choices with their own prompt — the VCS host and the CI provider —
505+
* where the caller supplies its own default to fall back on.
506+
*
507+
* @param {FleetAnalysis | null} analysis
508+
* @param {string} group
509+
* @return {string | null}
510+
*/
511+
export function fleetProposal(
512+
analysis: FleetAnalysis | null,
513+
group: string,
514+
): string | null {
515+
return analysis?.groups[group]?.propose || null;
516+
}
517+
397518
/**
398519
* One line per group explaining what was proposed and why, for the prompt.
399520
*
@@ -463,6 +584,20 @@ export function fleetNotes(analysis: FleetAnalysis): Record<string, string> {
463584
return notes;
464585
}
465586

587+
/**
588+
* The note for one group, ready to append to a prompt message, or `''`.
589+
*
590+
* @param {FleetAnalysis | null} analysis
591+
* @param {string} group
592+
* @return {string}
593+
*/
594+
export function fleetNote(
595+
analysis: FleetAnalysis | null,
596+
group: string,
597+
): string {
598+
return analysis ? fleetNotes(analysis)[group] || '' : '';
599+
}
600+
466601
/**
467602
* Records selections that contradict the scan, so the next run proposes what the
468603
* user actually wants.

0 commit comments

Comments
 (0)