feat(onboarding): add a summary for detected package files (#44258)

* refactor(onboarding): extract a function for calculating packageFiles

As part of a future change, we'll introduce a "summary" view for larger
repositories, and having a function extracted makes intent clearer.

Co-authored-by: Claude Sonnet 4.6 <jamie.tanna+claude-code@mend.io>

* refactor(onboarding): add a summary for detected package files

As part of future changes in #41502, we're introducing a
Platform-limit-aware onboarding/reconfigure flow that will provide a
summary where appropriate.

Before we do so, we can introduce the new functionality - but not
integrate it right now - to incrementally add support.

As a slightly better interface when we have a large list of detected
package files, we can provide a summary per Manager.

Co-authored-by: Claude Sonnet 4.6 <jamie.tanna+claude-code@mend.io>

* refactor(onboarding): group 3+ files under a directory as a list

As it's a more readable summary, especially with i.e. GitHub Actions,
where all package files commonly share the same directory.

The use of `3` as the lower bound was chosen without any research or too much
thought put into it.

Co-authored-by: Claude Sonnet 4.6 <jamie.tanna+claude-code@mend.io>

---------

Co-authored-by: Claude Sonnet 4.6 <jamie.tanna+claude-code@mend.io>
This commit is contained in:
Jamie Tanna
2026-06-27 16:14:00 +01:00
committed by GitHub
parent 5281211a30
commit bcd44b991f
3 changed files with 243 additions and 11 deletions
+4 -11
View File
@@ -32,6 +32,7 @@ import {
} from '../common.ts';
import { getBaseBranchDesc } from './base-branch.ts';
import { getConfigDesc } from './config-description.ts';
import { getPackageFilesDesc } from './package-files.ts';
import { getExpectedPrList } from './pr-list.ts';
/**
@@ -187,17 +188,9 @@ If you need any further assistance then you can also [request help here](${
);
prTemplate += rebaseCheckBox;
let prBody = prTemplate;
if (packageFiles && Object.entries(packageFiles).length) {
let files: string[] = [];
for (const [manager, managerFiles] of Object.entries(packageFiles)) {
files = files.concat(
managerFiles.map((file) => ` * \`${file.packageFile}\` (${manager})`),
);
}
prBody = `${prBody.replace(
'{{PACKAGE FILES}}',
`### Detected Package Files\n\n${files.join('\n')}`,
)}\n`;
const packageFilesDesc = getPackageFilesDesc(packageFiles);
if (packageFilesDesc) {
prBody = `${prBody.replace('{{PACKAGE FILES}}', packageFilesDesc)}\n`;
} else {
prBody = prBody.replace('{{PACKAGE FILES}}\n', '');
}
@@ -0,0 +1,177 @@
import { partial } from '~test/util.ts';
import type { PackageFile } from '../../../../modules/manager/types.ts';
import {
getPackageFilesDesc,
getPackageFilesSummary,
} from './package-files.ts';
describe('workers/repository/onboarding/pr/package-files', () => {
describe('getPackageFilesDesc()', () => {
it('returns empty string when packageFiles is null', () => {
expect(getPackageFilesDesc(null)).toBeEmptyString();
});
it('returns empty string when packageFiles is empty', () => {
expect(getPackageFilesDesc({})).toBeEmptyString();
});
it('returns formatted markdown for a single manager', () => {
const res = getPackageFilesDesc({
npm: [partial<PackageFile>({ packageFile: 'package.json' })],
});
expect(res).toBe('### Detected Package Files\n\n * `package.json` (npm)');
});
it('returns formatted markdown for multiple managers', () => {
const res = getPackageFilesDesc({
npm: [
partial<PackageFile>({ packageFile: 'package.json' }),
partial<PackageFile>({ packageFile: 'a/package.json' }),
],
dockerfile: [partial<PackageFile>({ packageFile: 'Dockerfile' })],
});
expect(res).toBe(
'### Detected Package Files\n\n * `package.json` (npm)\n * `a/package.json` (npm)\n * `Dockerfile` (dockerfile)',
);
});
it('returns formatted markdown for many files across managers, including a file handled by multiple managers', () => {
const res = getPackageFilesDesc({
npm: [
partial<PackageFile>({ packageFile: 'package.json' }),
partial<PackageFile>({ packageFile: 'frontend/package.json' }),
partial<PackageFile>({ packageFile: 'backend/package.json' }),
],
dockerfile: [
partial<PackageFile>({ packageFile: 'Dockerfile' }),
partial<PackageFile>({ packageFile: 'services/api/Dockerfile' }),
],
'docker-compose': [
partial<PackageFile>({ packageFile: 'docker-compose.yml' }),
],
'github-actions': [
partial<PackageFile>({ packageFile: '.github/workflows/ci.yml' }),
partial<PackageFile>({
packageFile: '.github/workflows/release.yml',
}),
],
regex: [partial<PackageFile>({ packageFile: 'Dockerfile' })],
});
expect(res).toBe(
'### Detected Package Files\n\n' +
' * `package.json` (npm)\n' +
' * `frontend/package.json` (npm)\n' +
' * `backend/package.json` (npm)\n' +
' * `Dockerfile` (dockerfile)\n' +
' * `services/api/Dockerfile` (dockerfile)\n' +
' * `docker-compose.yml` (docker-compose)\n' +
' * `.github/workflows/ci.yml` (github-actions)\n' +
' * `.github/workflows/release.yml` (github-actions)\n' +
' * `Dockerfile` (regex)',
);
});
});
describe('getPackageFilesSummary()', () => {
it('returns empty string when packageFiles is null', () => {
expect(getPackageFilesSummary(null)).toBeEmptyString();
});
it('returns empty string when packageFiles is empty', () => {
expect(getPackageFilesSummary({})).toBeEmptyString();
});
it('returns grouped markdown for a single manager', () => {
const res = getPackageFilesSummary({
npm: [partial<PackageFile>({ packageFile: 'package.json' })],
});
expect(res).toBe('#### npm\n\n * `package.json`');
});
it('groups files under a nested list when a directory has 3 or more files', () => {
const res = getPackageFilesSummary({
'github-actions': [
partial<PackageFile>({
packageFile: '.github/workflows/ci.yml',
}),
partial<PackageFile>({
packageFile: '.github/workflows/release.yml',
}),
partial<PackageFile>({
packageFile: '.github/workflows/lint.yml',
}),
],
});
expect(res).toBe(
'#### github-actions\n\n' +
' * `.github/workflows/`\n' +
' * `ci.yml`\n' +
' * `release.yml`\n' +
' * `lint.yml`',
);
});
it('lists files individually when a directory has fewer than 3 files, and groups when it meets the threshold', () => {
const res = getPackageFilesSummary({
'github-actions': [
partial<PackageFile>({ packageFile: '.github/workflows/ci.yml' }),
partial<PackageFile>({
packageFile: '.github/workflows/release.yml',
}),
partial<PackageFile>({
packageFile: '.github/workflows/lint.yml',
}),
partial<PackageFile>({ packageFile: '.github/dependabot.yml' }),
],
});
expect(res).toBe(
'#### github-actions\n\n' +
' * `.github/workflows/`\n' +
' * `ci.yml`\n' +
' * `release.yml`\n' +
' * `lint.yml`\n' +
' * `.github/dependabot.yml`',
);
});
it('returns grouped markdown for many files across managers, including a file handled by multiple managers', () => {
const res = getPackageFilesSummary({
npm: [
partial<PackageFile>({ packageFile: 'package.json' }),
partial<PackageFile>({ packageFile: 'frontend/package.json' }),
partial<PackageFile>({ packageFile: 'backend/package.json' }),
],
dockerfile: [
partial<PackageFile>({ packageFile: 'Dockerfile' }),
partial<PackageFile>({ packageFile: 'services/api/Dockerfile' }),
],
'docker-compose': [
partial<PackageFile>({ packageFile: 'docker-compose.yml' }),
],
'github-actions': [
partial<PackageFile>({ packageFile: '.github/workflows/ci.yml' }),
partial<PackageFile>({
packageFile: '.github/workflows/release.yml',
}),
],
regex: [partial<PackageFile>({ packageFile: 'Dockerfile' })],
});
expect(res).toBe(
'#### npm\n\n' +
' * `package.json`\n' +
' * `frontend/package.json`\n' +
' * `backend/package.json`\n\n' +
'#### dockerfile\n\n' +
' * `Dockerfile`\n' +
' * `services/api/Dockerfile`\n\n' +
'#### docker-compose\n\n' +
' * `docker-compose.yml`\n\n' +
'#### github-actions\n\n' +
' * `.github/workflows/ci.yml`\n' +
' * `.github/workflows/release.yml`\n\n' +
'#### regex\n\n' +
' * `Dockerfile`',
);
});
});
});
@@ -0,0 +1,62 @@
import type { PackageFile } from '../../../../modules/manager/types.ts';
const GROUP_THRESHOLD = 3;
function formatManagerFiles(files: PackageFile[]): string {
const byDir = new Map<string, string[]>();
for (const { packageFile } of files) {
const lastSlash = packageFile.lastIndexOf('/');
const dir = lastSlash >= 0 ? packageFile.slice(0, lastSlash + 1) : '';
const name =
lastSlash >= 0 ? packageFile.slice(lastSlash + 1) : packageFile;
const existing = byDir.get(dir);
if (existing) {
existing.push(name);
} else {
byDir.set(dir, [name]);
}
}
const lines: string[] = [];
for (const [dir, names] of byDir) {
if (dir && names.length >= GROUP_THRESHOLD) {
lines.push(` * \`${dir}\``);
for (const name of names) {
lines.push(` * \`${name}\``);
}
} else {
for (const name of names) {
lines.push(` * \`${dir}${name}\``);
}
}
}
return lines.join('\n');
}
export function getPackageFilesSummary(
packageFiles: Record<string, PackageFile[]> | null,
): string {
if (!packageFiles || !Object.entries(packageFiles).length) {
return '';
}
const sections: string[] = [];
for (const [manager, managerFiles] of Object.entries(packageFiles)) {
sections.push(`#### ${manager}\n\n${formatManagerFiles(managerFiles)}`);
}
return sections.join('\n\n');
}
export function getPackageFilesDesc(
packageFiles: Record<string, PackageFile[]> | null,
): string {
if (!packageFiles || !Object.entries(packageFiles).length) {
return '';
}
let files: string[] = [];
for (const [manager, managerFiles] of Object.entries(packageFiles)) {
files = files.concat(
managerFiles.map((file) => ` * \`${file.packageFile}\` (${manager})`),
);
}
return `### Detected Package Files\n\n${files.join('\n')}`;
}