Last active
August 17, 2021 18:13
-
-
Save wbern/d0ffb116804b2c4bb61da10249500163 to your computer and use it in GitHub Desktop.
rush-changed-projects (some process.env vars might be org-specific)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export interface FilesInProjectsOrGlobal { | |
[key: string]: string[]; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | |
}); | |
}); | |
}); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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