Skip to content

Instantly share code, notes, and snippets.

@abereghici
Forked from wbern/bin.ts
Created June 16, 2021 10:33
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save abereghici/5b227b85ab2752560e4a6b6be27eaab7 to your computer and use it in GitHub Desktop.
Save abereghici/5b227b85ab2752560e4a6b6be27eaab7 to your computer and use it in GitHub Desktop.
rush-changed-projects (some process.env vars might be org-specific)
import { getCommand } from "./lib";
import { printCommand } from "./util";
import process from "process";
function main() {
const [
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
__,
command = "sleep",
buildDirections = "['--to', '--from']",
forceChangedProjects = "",
] = process.argv;
const commandToExecute = getCommand(command, buildDirections, forceChangedProjects);
printCommand(commandToExecute);
}
main();
export interface FilesInProjectsOrGlobal {
[key: string]: string[];
}
import * as util from "./util";
import * as lib from "./lib";
const actualUtil = jest.requireActual("./util");
const mockedUtil = util as jest.Mocked<typeof util>;
const mockCommits = [
// latest commit
"abcdef",
"hijkmn",
"opqrst",
];
jest.mock("./util");
const exec = (forceChangedProjects?: string) =>
lib.getCommand("sleep", "['--to', '--from']", forceChangedProjects);
[true, false].forEach((CI) => {
const getMockingMethodForChangedFiles = () =>
CI ? mockedUtil.getChangesFromCommit : mockedUtil.getStagedChanges;
const commitToCompareFrom = CI
? mockCommits[Math.floor(Math.random() * mockCommits.length)]
: mockCommits[0];
describe(`Given we are running the script on ${
CI ? "the jenkins pipeline" : "a local machine"
}`, () => {
beforeEach(() => {
jest.clearAllMocks();
mockedUtil.parseBuildDirectionsString = actualUtil.parseBuildDirectionsString;
mockedUtil.getCurrentCommit.mockImplementation(() => mockCommits[0]);
// @ts-expect-error we dont want to stub the complete rush config
mockedUtil.getRushConfig.mockImplementation(() => {
return {
rushJsonFolder: "/home/user/repo",
projects: [
{ packageName: "project-a", projectFolder: "/home/user/repo/apps/project-a" },
{ packageName: "project-b", projectFolder: "/home/user/repo/apps/project-b" },
{ packageName: "project-c", projectFolder: "/home/user/repo/apps/project-c" },
],
};
});
mockedUtil.isCI.mockReturnValue(CI);
if (CI) {
mockedUtil.getPreviousSuccessfulCommit.mockReturnValueOnce(commitToCompareFrom);
}
});
describe("and we have some changes inside a project", () => {
it("should output the input command, plus found project.", () => {
getMockingMethodForChangedFiles().mockImplementationOnce(() => [
"apps/project-a/src/index.js",
]);
const expectedResult = "sleep --to project-a --from project-a";
expect(exec()).toBe(expectedResult);
getMockingMethodForChangedFiles().mockImplementationOnce(() => [
"apps/project-a/src/index.js",
"apps/project-a/src/util.js",
"apps/project-a/package.json",
]);
expect(exec()).toBe(expectedResult);
});
describe("but the file is specified to be ignored", () => {
it("should output a voiding echo command", () => {
getMockingMethodForChangedFiles().mockImplementationOnce(() => [
"apps/project-a/src/index.js",
]);
mockedUtil.isProjectFileToBeIgnoredForChangeDetection.mockImplementationOnce(() => true);
expect(exec()).toBe(
`echo "skipping sleep because no projects have changed from commit hash ${commitToCompareFrom}"`
);
});
});
});
describe("and we have some changes inside multiple projects", () => {
it("should output the input command, plus found project(s).", () => {
getMockingMethodForChangedFiles().mockImplementationOnce(() => [
"apps/project-a/src/index.js",
"apps/project-b/src/index.js",
]);
const expectedResult =
"sleep --to project-a --from project-a --to project-b --from project-b";
expect(exec()).toBe(expectedResult);
getMockingMethodForChangedFiles().mockImplementationOnce(() => [
"apps/project-a/src/index.js",
"apps/project-b/package.json",
"apps/project-b/src/index.js",
"apps/project-b/package.json",
]);
expect(exec()).toBe(expectedResult);
});
});
it("should output the input command, plus the forced projects", async () => {
getMockingMethodForChangedFiles().mockImplementationOnce(() => [
"apps/project-a/src/index.js",
"apps/project-b/src/index.js",
]);
const expectedResult =
"sleep --to project-a --from project-a --to project-b --from project-b";
expect(exec("project-a")).toBe(expectedResult);
});
describe("and we have modified a file outside the projects", () => {
it("should build everything", () => {
getMockingMethodForChangedFiles().mockImplementationOnce(() => ["package.json"]);
expect(exec()).toBe("sleep");
});
describe("that global file is specified to be safe from side-effects to projects", () => {
it("should output a voiding echo command", () => {
getMockingMethodForChangedFiles().mockImplementationOnce(() => ["package.json"]);
mockedUtil.isGlobalFileSafeFromSideEffects.mockImplementationOnce(() => true);
expect(exec()).toBe(
`echo "skipping sleep because no projects have changed from commit hash ${commitToCompareFrom}"`
);
});
});
describe("but we have specified some project to be forced to be included", () => {
it("should output the input command, plus the forced projects", () => {
getMockingMethodForChangedFiles().mockImplementationOnce(() => ["package.json"]);
expect(exec("project-a")).toBe("sleep");
});
});
});
describe("and we have not made any changes", () => {
it("should output a voiding echo command", () => {
getMockingMethodForChangedFiles().mockImplementationOnce(() => []);
expect(exec()).toBe(
`echo "skipping sleep because no projects have changed from commit hash ${commitToCompareFrom}"`
);
});
describe("but we have specified some project to be forced to be included", () => {
it("should output the input command, plus the forced projects", () => {
getMockingMethodForChangedFiles().mockImplementationOnce(() => []);
expect(exec("project-a")).toBe("sleep --to project-a --from project-a");
});
});
});
});
});
import { RushConfigurationProject } from "@microsoft/rush-lib";
import path from "path";
import {
printDebug,
isCI,
parseBuildDirectionsString,
getRushConfig,
getChangesFromCommit,
getBranchNameInPipeline,
getCurrentCommit,
getStagedChanges,
getMergeBaseCommit,
getCustomCompareGitCommit,
getPreviousSuccessfulCommit,
isProjectFileToBeIgnoredForChangeDetection,
isGlobalFileSafeFromSideEffects,
} from "./util";
import { FilesInProjectsOrGlobal } from "./interfaces";
export function getCommand(
command: string,
buildDirectionsJson = "['--to', '--from']",
forceChangedProjects = ""
): string {
const rushJson = getRushConfig();
const buildDirections: string[] = parseBuildDirectionsString(buildDirectionsJson);
let commitToCompareFrom;
let files;
if (isCI()) {
const previousSuccessfulBuildCommit = getPreviousSuccessfulCommit();
if (getBranchNameInPipeline() === "master" && previousSuccessfulBuildCommit === null) {
// master is brand new. This is rare/untested, but that likely means we'll want execute for every project.
return command;
}
commitToCompareFrom =
getCustomCompareGitCommit() || previousSuccessfulBuildCommit || getMergeBaseCommit();
if (commitToCompareFrom === null) {
// might be first commit ever made? Execute everything
return command;
}
printDebug(
"[CI] comparing with commit hash " +
(previousSuccessfulBuildCommit
? "(from a previous successful build) "
: "(from last ancestor commit to master) ") +
commitToCompareFrom
);
files = getChangesFromCommit(commitToCompareFrom);
} else {
// executing on local machine
commitToCompareFrom = getCurrentCommit();
if (commitToCompareFrom === null) {
// might be first commit ever made? Execute everything
return command;
}
printDebug("[Local] comparing local staged files with latest commit " + commitToCompareFrom);
files = getStagedChanges(commitToCompareFrom);
}
const categorizedFiles: FilesInProjectsOrGlobal = {
"[global]": [],
"[global]-ignored": [],
};
const changedProjects = files.reduce((projects: Record<string, string[]>, file) => {
const resolvedFile = path.join(rushJson.rushJsonFolder, file);
const mapFileToProject = () =>
rushJson.projects.some((project: RushConfigurationProject) => {
if (resolvedFile.startsWith(project.projectFolder)) {
categorizedFiles[project.packageName] = categorizedFiles[project.packageName] || [];
if (isProjectFileToBeIgnoredForChangeDetection([], file)) {
categorizedFiles[`${project.packageName}-ignored`] =
categorizedFiles[`${project.packageName}-ignored`] || [];
categorizedFiles[`${project.packageName}-ignored`].push(file);
return true;
} else {
projects[project.packageName] = projects[project.packageName] || [];
categorizedFiles[project.packageName].push(file);
projects[project.packageName].push(file);
return true;
}
}
return false;
});
if (!mapFileToProject()) {
if (
!isGlobalFileSafeFromSideEffects(
[".dev-proxyrc.js", "README.MD", "jsconfig.json", ".vscode/launch.json", "chart"],
file
)
) {
categorizedFiles["[global]"].push(file);
} else {
categorizedFiles["[global]-ignored"].push(file);
}
}
return projects;
}, {});
printDebug(
"changes:\n" +
Object.keys(categorizedFiles)
.filter((key) => categorizedFiles[key].length)
.map((key) => [`------------------\nScope: ${key}\n`, ...categorizedFiles[key]])
.flatMap((item) => item)
.join("\n") +
"\n\n"
);
if (categorizedFiles["[global]"].length > 0) {
return command;
}
if (Object.keys(changedProjects).length === 0 && forceChangedProjects === "") {
return `echo "skipping ${command} because no projects have changed from commit hash ${commitToCompareFrom}"`;
}
const parsedForceChangedProjects = forceChangedProjects
.split(" ")
.filter(
(projectName) => projectName !== "" && !Object.keys(changedProjects).includes(projectName)
)
.map((projectName) => buildDirections.flatMap((d) => [d, projectName]).join(" "));
const commandToRun =
[
command,
...Object.keys(changedProjects).flatMap((projectName) => {
return buildDirections.flatMap((d) => [d, projectName]);
}),
].join(" ") +
(parsedForceChangedProjects.length > 0 ? " " + parsedForceChangedProjects.join(" ") : "");
return commandToRun;
}
import { spawnSync } from "child_process";
import process from "process";
import path from "path";
import fs from "fs";
import { RushConfiguration } from "@microsoft/rush-lib";
const debug = true;
export function getRushConfig(): RushConfiguration {
const repoRoot = path.join(__dirname, "../../../");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let rushJson: any;
eval(`rushJson = ${fs.readFileSync(path.join(repoRoot, "rush.json")).toString()}`);
rushJson.rushJsonFolder = repoRoot;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
rushJson.projects = rushJson.projects.map((project: any) => ({
...project,
projectFolder: path.resolve(repoRoot, project.projectFolder),
}));
return rushJson as RushConfiguration;
}
export function printDebug(msg: string): void {
if (debug) {
process.stderr.write(msg);
}
}
export function isProjectFileToBeIgnoredForChangeDetection(
ignoreArray: string[],
file: string
): boolean {
return ignoreArray.includes(file);
}
export function isGlobalFileSafeFromSideEffects(ignoreArray: string[], file: string): boolean {
return ignoreArray.includes(file);
}
export function getMergeBaseCommit(): string {
return spawnSync("git", ["merge-base", "HEAD", "origin/master"]).stdout.toString().trim();
}
export function getChangesFromCommit(commitToCompareFrom: string): string[] {
return spawnSync("git", [
"--no-pager",
"diff",
"--name-only",
"--staged",
"--no-renames",
commitToCompareFrom,
])
.stdout.toString()
.trim()
.split("\n")
.filter((item) => item !== "");
}
export function printCommand(command: string): void {
// output will be consumed as a command in Jenkinsfile
// eslint-disable-next-line
console.log(command);
}
export function getCurrentCommit(): string | null {
try {
return spawnSync("git", ["rev-parse", "HEAD"]).stdout.toString().trim();
} catch (e) {
return null;
}
}
export function getStagedChanges(commitToCompareFrom: string): string[] {
return spawnSync("git", ["--no-pager", "diff", "--name-only", "--staged", commitToCompareFrom])
.stdout.toString()
.trim()
.split("\n")
.filter((item) => item !== "");
}
export function getCustomCompareGitCommit(): string | null {
return (process.env && process.env.CUSTOM_COMPARE_GIT_COMMIT) || null;
}
export function getPreviousSuccessfulCommit(): string | null {
if (
process.env &&
typeof process.env.GIT_PREVIOUS_NON_FAILED_COMMIT === "string" &&
/[0-9a-f]{7,40}/.test(process.env.GIT_PREVIOUS_NON_FAILED_COMMIT || "")
) {
return process.env.GIT_PREVIOUS_NON_FAILED_COMMIT;
}
return null;
}
export function getBranchNameInPipeline(): string | null {
return process.env?.BRANCH_NAME || null;
}
export function isCI(): boolean {
return !!getBranchNameInPipeline();
}
export function parseBuildDirectionsString(buildDirectionsJson: string): string[] {
try {
return JSON.parse(buildDirectionsJson.replace(/['`]/g, '"'));
} catch (e) {
throw new Error(
`Could not parse second argument "buildDirections"; needs to be an array like ['--to', '--from']`
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment