Skip to content

Commit

Permalink
Merge pull request #83 from backstage/add-changeset-action
Browse files Browse the repository at this point in the history
feat: add post changeset feedback action
  • Loading branch information
jhaals authored Dec 9, 2022
2 parents 9399123 + 8d34d7e commit 19c3636
Show file tree
Hide file tree
Showing 9 changed files with 584 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
node_modules
.idea
Binary file modified .yarn/install-state.gz
Binary file not shown.
19 changes: 19 additions & 0 deletions changeset-feedback/action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: Backstage Changeset Feedback
description: Action to post the changeset feedback on a PR
inputs:
github-token:
description: The GitHub token used to create an authenticated client
default: ${{ github.token }}
required: true
marker:
description: Marker to check if there is already a changeset posted on the PR
default: '<!-- changeset-feedback -->'
required: false
diffRef:
description: The target branch to use to list the changes
default: origin/master
required: false
outputs: {}
runs:
using: node16
main: ./entry.js
8 changes: 8 additions & 0 deletions changeset-feedback/entry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
require('../.pnp.cjs').setup();

require('ts-node').register({
transpileOnly: true,
project: require('path').resolve(__dirname, '../tsconfig.json'),
});

require('./index');
309 changes: 309 additions & 0 deletions changeset-feedback/generateFeedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import fs from 'fs';
import { execFile as execFileCb } from 'child_process';
import { promisify } from 'util';
import {
basename,
resolve as resolvePath,
relative as relativePath,
} from 'path';

const execFile = promisify(execFileCb);

// Tells whether a path relative to the package directory has an effect
// on the published package
function isPublishedPath(path: string) {
if (path.startsWith('dev/')) {
return false;
}
if (path.includes('__mocks__')) {
return false;
}
if (path.includes('__fixtures__')) {
return false;
}
// Don't count manual modifications to the changelog
if (path === 'CHANGELOG.md') {
return false;
}
// API report changes by themselves don't count
if (path === 'api-report.md' || path === 'cli-report.md') {
return false;
}
// Lint changes don't count
if (path === '.eslintrc.js') {
return false;
}

const name = basename(path);
if (name.startsWith('setupTests.')) {
return false;
}
if (name.includes('.test.')) {
return false;
}
if (name.includes('.stories.')) {
return false;
}
return true;
}

export async function listChangedFiles(ref: string) {
if (!ref) {
throw new Error('ref is required');
}

const { stdout } = await execFile('git', ['diff', '--name-only', ref]);
return stdout.trim().split(/\r?\n/);
}

export async function listPackages() {
const rootPkg = require(resolvePath(process.cwd(), './package.json'));
if (!rootPkg?.workspaces?.packages) {
throw new Error('No workspaces found in root package.json');
}

const pkgs: Array<{ path: string; name: string }> = [];

// Naive workspace lookup implementation, we can't shell out to yarn here as the implementation embedded in the repo
for (const pkgPath of rootPkg?.workspaces?.packages) {
const readDirRecursive = (dir: string, parts: string) => {
const [nextPart] = parts;

// We've reached the end of the path pattern, check if package.json exists
if (!nextPart) {
try {
const pkg = require(resolvePath(dir, 'package.json'));
pkgs.push({
path: relativePath(process.cwd(), dir),
name: pkg.name,
});
} catch {
process.stderr.write(`Failed to read package.json in ${dir}\n`);
}
return;
}

for (const filePath of fs.readdirSync(dir)) {
if (fs.statSync(resolvePath(dir, filePath)).isDirectory()) {
if (filePath === nextPart || nextPart === '*') {
readDirRecursive(resolvePath(dir, filePath), parts.slice(1));
}
}
}
};

// Split the workspace paths by / and check each directory level recursively
readDirRecursive(process.cwd(), pkgPath.split('/'));
}

return pkgs;
}

export async function loadChangesets(filePaths: string[]) {
const changesets = [];
for (const filePath of filePaths) {
if (!filePath.startsWith('.changeset/') || !filePath.endsWith('.md')) {
continue;
}
try {
const content = await fs.promises.readFile(filePath, 'utf8');
let lines = content.split(/\r?\n/);

lines = lines.slice(lines.findIndex(line => line === '---') + 1);
lines = lines.slice(
0,
lines.findIndex(line => line === '---'),
);

const bumps: Map<string, string> & { toJSON?: () => any } = new Map();
bumps.toJSON = () => Object.fromEntries(bumps);
for (const line of lines) {
const match = line.match(/^'(.*)': (patch|minor|major)$/);
if (!match) {
throw new Error(`Invalid changeset line: ${line}`);
}

bumps.set(match[1], match[2]);
}

changesets.push({
filePath,
bumps,
});
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
}

return changesets;
}

export async function listChangedPackages(
changedFiles: string[],
packages: { name: any; path: any }[],
) {
const changedPackageMap = new Map();
for (const filePath of changedFiles) {
for (const pkg of packages) {
if (filePath.startsWith(`${pkg.path}/`)) {
const pkgPath = relativePath(pkg.path, filePath);
if (!isPublishedPath(pkgPath)) {
break;
}
const entry = changedPackageMap.get(pkg.name);
if (entry) {
entry.files.push(pkgPath);
} else {
const pkgJson = require(resolvePath(pkg.path, 'package.json'));

changedPackageMap.set(pkg.name, {
...pkg,
version: pkgJson.version,
isStable: !pkgJson.version.startsWith('0.'),
isPrivate: Boolean(pkgJson.private),
files: [pkgPath],
});
}
break;
}
}
}
return Array.from(changedPackageMap.values());
}

function formatSection(
prefix: string | string[] = [],
generator: {
(): Generator<string, void, unknown>;
(): ArrayLike<unknown> | Iterable<unknown>;
},
suffix: string | string[] = [''],
) {
const lines = Array.from(generator());
if (lines.length === 0) {
return '';
}

return [...[prefix].flat(), ...lines, ...[suffix].flat(), '', ''].join('\n');
}

export function formatSummary(changedPackages: any[], changesets: any[]) {
const changedNames = new Set(
changedPackages.map((pkg: { name: any }) => pkg.name),
);

let output = '';

output += formatSection(
`## Missing Changesets
The following package(s) are changed by this PR but do not have a changeset:
`,
function* section() {
for (const pkg of changedPackages) {
if (
changesets.some((c: { bumps: { get: (arg0: any) => any } }) =>
c.bumps.get(pkg.name),
)
) {
continue;
}
if (pkg.isPrivate) {
continue;
}
yield `- **${pkg.name}**`;
}
},
`
See [CONTRIBUTING.md](https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md#creating-changesets) for more information about how to add changesets.
`,
);

output += formatSection(
`## Unexpected Changesets
The following changeset(s) reference packages that have not been changed in this PR:
`,
function* section() {
for (const c of changesets) {
const missing = Array.from(c.bumps.keys()).filter(
b => !changedNames.has(b),
);
if (missing.length > 0) {
yield `- **${c.filePath}**: ${missing.join(', ')}`;
}
}
},
`
Note that only changes that affect the published package require changesets, for example changes to tests and storybook stories do not require changesets.
`,
);

output += formatSection(
`## Unnecessary Changesets
The following package(s) are private and do not need a changeset:
`,
function* section() {
for (const pkg of changedPackages) {
if (
changesets.some((c: { bumps: { get: (arg0: any) => any } }) =>
c.bumps.get(pkg.name),
) &&
pkg.isPrivate
) {
yield `- **${pkg.name}**`;
}
}
},
);

output += formatSection(
`## Changed Packages
| Package Name | Package Path | Changeset Bump | Current Version |
|:-------------|:-------------|:--------------:|:----------------|`,
function* section() {
const bumpMap: { [key: string]: number } = {
undefined: -1,
patch: 0,
minor: 1,
major: 2,
};

for (const pkg of changedPackages) {
const maxBump =
changesets
.map((c: { bumps: { get: (arg0: any) => any } }) =>
c.bumps.get(pkg.name),
)
.reduce(
(max: string | number, bump: string | number) =>
bumpMap[bump] > bumpMap[max] ? bump : max,
undefined,
) ?? 'none';
yield `| ${pkg.name} | ${pkg.path} | **${maxBump}** | \`v${pkg.version}\` |`;
}
},
);

return output;
}
7 changes: 7 additions & 0 deletions changeset-feedback/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { describe, it, expect } from '@jest/globals';

describe('test', () => {
it('should test', () => {
expect(1).toBe(1);
});
});
41 changes: 41 additions & 0 deletions changeset-feedback/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import * as core from '@actions/core';
import * as github from '@actions/github';
import { createAppClient } from '../lib/createAppClient';
import { postFeedback } from './postFeedback';
import {
formatSummary,
listChangedFiles,
listChangedPackages,
listPackages,
loadChangesets,
} from './generateFeedback';

async function main() {
core.info('Running changeset feedback');

const client = createAppClient();
const marker = core.getInput('marker', { required: true });
const diffRef = core.getInput('diffRef', { required: true });
const issueNumberStr = core.getInput('issue-number', { required: true });
const changedFiles = await listChangedFiles(diffRef);
const packages = await listPackages();
const changesets = await loadChangesets(changedFiles);
const changedPackages = await listChangedPackages(changedFiles, packages);
const repoInfo = github.context.repo;
const feedback = formatSummary(changedPackages, changesets);

core.info(feedback);

await postFeedback(client, {
...repoInfo,
issueNumberStr,
marker,
feedback,
});
}

main().catch(error => {
core.error(error.stack);
core.setFailed(String(error));
process.exit(1);
});
Loading

0 comments on commit 19c3636

Please sign in to comment.