@decaf-ts/utils
Version:
module management utils for decaf-ts
353 lines • 13.2 kB
JavaScript
import fs from "fs";
import path from "path";
import { execSync } from "node:child_process";
import { Logging } from "@decaf-ts/logging";
const releaseLog = Logging.for("ReleaseChainRunner");
export class ReleaseChainRunner {
constructor(options) {
this.options = options;
this.workspace = options.workspace ?? process.cwd();
this.clonesRoot = path.join(this.workspace, ".release-chain");
this.token =
options.token ||
process.env.RELEASE_CHAIN_TOKEN ||
process.env.GITHUB_TOKEN ||
process.env.GH_TOKEN;
this.metaRepoSlug =
normalizeRepoSlug(options.metaRepoUrl) ||
throwOnMissing("Unable to normalize META repo URL");
this.currentRepo =
normalizeRepoSlug(options.currentRepo) || detectCurrentRepoSlug();
}
async run() {
const modules = await this.loadSubmodules();
if (!modules.length) {
releaseLog.warn("No modules detected in provided .gitmodules file");
return;
}
const startIndex = this.currentRepo
? modules.findIndex((module) => module.slug === this.currentRepo)
: -1;
const queue = startIndex >= 0 ? modules.slice(startIndex + 1) : modules;
releaseLog.info(`Evaluating ${queue.length} repositories for ${this.options.packageName}`);
for (const module of queue) {
await this.evaluateModule(module);
}
}
async evaluateModule(module) {
if (!module.slug) {
releaseLog.verbose(`Skipping ${module.name}: unsupported repository URL`);
return;
}
releaseLog.info(`\n[release-chain] ${module.slug}`);
const repoDir = path.join(this.clonesRoot, module.slug.replace(/\//g, "__"));
fs.rmSync(repoDir, { recursive: true, force: true });
fs.mkdirSync(repoDir, { recursive: true });
this.cloneRepository(module.url, repoDir);
await this.checkoutTargetBranch(repoDir);
const pkgPath = path.join(repoDir, "package.json");
if (!fs.existsSync(pkgPath)) {
releaseLog.debug(`No package.json detected for ${module.slug}`);
return;
}
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
if (!dependsOnPackage(pkg, this.options.packageName)) {
releaseLog.debug(`${module.slug} does not depend on ${this.options.packageName}`);
return;
}
ensureGitIdentity(repoDir);
this.runCommand("npm install --ignore-scripts", repoDir);
this.runCommand(`npm update ${this.options.packageName}`, repoDir);
this.runCommand("npm run build --if-present", repoDir);
this.runCommand("npm run test --if-present", repoDir);
if (!hasChanges(repoDir)) {
releaseLog.info(`No changes detected for ${module.slug}`);
return;
}
this.runCommand("git add -A", repoDir);
this.runCommand(`git commit -m "chore: release chain update for ${this.options.packageName}"`, repoDir);
this.runCommand(`git push origin ${this.options.branch} --force-with-lease`, repoDir);
const existingPr = await this.findExistingPr(module.slug);
if (existingPr) {
releaseLog.info(`Existing PR #${existingPr.number} detected for ${module.slug}, attempting merge`);
await this.acceptPullRequest(module.slug, existingPr.number, existingPr.head.sha);
return;
}
const baseBranch = this.options.targetBaseBranch ||
(await this.getDefaultBranch(module.slug));
await this.createPullRequest(module.slug, baseBranch);
}
cloneRepository(url, target) {
const authUrl = injectAuth(url, this.token);
this.runCommand(`git clone --no-tags --depth 1 ${authUrl} ${target}`);
}
async checkoutTargetBranch(cwd) {
try {
this.runCommand(`git fetch origin ${this.options.branch}:${this.options.branch}`, cwd);
this.runCommand(`git checkout ${this.options.branch}`, cwd);
this.runCommand(`git reset --hard origin/${this.options.branch}`, cwd);
return;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
}
catch (error) {
releaseLog.verbose(`Branch ${this.options.branch} not found remotely; creating from default`);
}
const defaultBranch = detectDefaultBranch(cwd);
this.runCommand(`git checkout -b ${this.options.branch} origin/${defaultBranch}`, cwd);
}
async loadSubmodules() {
if (this.options.submodulePath &&
fs.existsSync(this.options.submodulePath)) {
const localContent = fs.readFileSync(this.options.submodulePath, "utf8");
return parseSubmodules(localContent);
}
const candidates = [
this.options.submoduleFile,
".gitsubmodule",
".gitmodules",
].filter(Boolean);
for (const file of candidates) {
try {
const content = await fetchRawFile(this.metaRepoSlug, this.options.branch, file, this.token);
if (content) {
return parseSubmodules(content);
}
}
catch (error) {
if (file === candidates[candidates.length - 1]) {
throw error;
}
}
}
return [];
}
runCommand(command, cwd = this.workspace) {
releaseLog.debug(`[${cwd}] ${command}`);
execSync(command, {
cwd,
stdio: "inherit",
env: Object.assign({}, process.env, {
RELEASE_CHAIN_TOKEN: this.token,
}),
});
}
async githubRequest(path, init) {
const headers = {
"User-Agent": "decaf-release-chain",
Accept: "application/vnd.github+json",
};
if (this.token) {
headers.Authorization = `Bearer ${this.token}`;
}
const response = await fetch(`https://api.github.com${path}`, {
method: init?.method || "GET",
headers: Object.assign({}, headers, init?.headers),
body: init?.body,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`GitHub API ${response.status}: ${text}`);
}
if (response.status === 204) {
return undefined;
}
return (await response.json());
}
async findExistingPr(slug) {
const [owner] = slug.split("/");
const list = await this.githubRequest(`/repos/${slug}/pulls?head=${owner}%3A${encodeURIComponent(this.options.branch)}&state=open&per_page=1`);
return list?.[0];
}
async acceptPullRequest(slug, number, sha) {
await this.githubRequest(`/repos/${slug}/pulls/${number}/merge`, {
method: "PUT",
body: JSON.stringify({
merge_method: "squash",
commit_title: `chore: accept release chain for ${this.options.packageName}`,
sha,
}),
});
}
async createPullRequest(slug, base) {
await this.githubRequest(`/repos/${slug}/pulls`, {
method: "POST",
body: JSON.stringify({
title: `${this.options.branch}-release-chain`,
head: this.options.branch,
base,
body: `Automated dependency update for ${this.options.packageName}.`,
}),
});
}
async getDefaultBranch(slug) {
const repo = await this.githubRequest(`/repos/${slug}`);
return repo?.default_branch || "main";
}
}
export async function runReleaseChain(options) {
const runner = new ReleaseChainRunner(options);
await runner.run();
}
export async function dispatchReleaseChainWorkflow(options) {
const token = options.token ||
process.env.RELEASE_CHAIN_TRIGGER_TOKEN ||
process.env.RELEASE_CHAIN_TOKEN ||
process.env.GITHUB_TOKEN ||
process.env.GH_TOKEN;
const repoSlug = options.repoSlug ||
detectCurrentRepoSlug() ||
throwOnMissing("Unable to determine repository slug for dispatch");
const workflow = options.workflowFile || "release-chain.yaml";
const ref = options.ref || options.branch;
const currentRepo = normalizeRepoSlug(options.currentRepo) || repoSlug;
releaseLog.info(`Dispatching ${workflow} on ${repoSlug} for ${options.branch} (current repo ${currentRepo})`);
const headers = {
"User-Agent": "decaf-release-chain-dispatch",
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`https://api.github.com/repos/${repoSlug}/actions/workflows/${workflow}/dispatches`, {
method: "POST",
headers,
body: JSON.stringify({
ref,
inputs: {
meta_repo_url: options.metaRepoUrl,
branch: options.branch,
current_repo: currentRepo,
target_branch: options.targetBaseBranch || "",
},
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Workflow dispatch failed: ${response.status} ${text}`);
}
}
function parseSubmodules(content) {
return content
.split("[submodule")
.slice(1)
.map((block) => {
const nameMatch = block.match(/"(.+?)"\]/);
const pathMatch = block.match(/path\s*=\s*(.+)/);
const urlMatch = block.match(/url\s*=\s*(.+)/);
const url = urlMatch?.[1]?.trim() || "";
return {
name: nameMatch?.[1]?.trim() || "",
path: pathMatch?.[1]?.trim() || "",
url,
slug: normalizeRepoSlug(url),
};
})
.filter((entry) => entry.url);
}
function normalizeRepoSlug(value) {
if (!value)
return undefined;
if (value.includes("github.com")) {
const normalized = toHttpsUrl(value);
const parsed = new URL(normalized);
return parsed.pathname.replace(/^\//, "").replace(/\.git$/, "");
}
return value.replace(/^\//, "");
}
function toHttpsUrl(url) {
if (url.startsWith("git@github.com:")) {
return `https://github.com/${url.replace("git@github.com:", "")}`;
}
if (url.startsWith("ssh://git@github.com/")) {
return `https://github.com/${url.replace("ssh://git@github.com/", "")}`;
}
return url.replace(/\.git$/, "");
}
function injectAuth(url, token) {
const httpsUrl = toHttpsUrl(url);
if (!token) {
return httpsUrl;
}
const parsed = new URL(httpsUrl);
parsed.username = "x-access-token";
parsed.password = token;
return parsed.toString();
}
async function fetchRawFile(slug, branch, file, token) {
const url = `https://raw.githubusercontent.com/${slug}/${branch}/${file}`;
const headers = {
"User-Agent": "decaf-release-chain",
};
if (token)
headers.Authorization = `Bearer ${token}`;
const response = await fetch(url, { headers });
if (response.status === 404) {
return null;
}
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to download ${file}: ${response.status} ${text}`);
}
return response.text();
}
function detectCurrentRepoSlug() {
if (process.env.GITHUB_REPOSITORY) {
return process.env.GITHUB_REPOSITORY;
}
try {
const remote = execSync("git config --get remote.origin.url", {
encoding: "utf8",
})
.trim()
.replace(/\s+/g, "");
return normalizeRepoSlug(remote);
}
catch {
return undefined;
}
}
function detectDefaultBranch(cwd) {
try {
const ref = execSync("git symbolic-ref --short refs/remotes/origin/HEAD", {
cwd,
encoding: "utf8",
}).trim();
return ref.split("/").pop() || "main";
}
catch {
return "main";
}
}
function hasChanges(cwd) {
const status = execSync("git status --porcelain", {
cwd,
encoding: "utf8",
}).trim();
return Boolean(status);
}
function ensureGitIdentity(cwd) {
try {
execSync("git config user.name", { cwd, stdio: "pipe" });
execSync("git config user.email", { cwd, stdio: "pipe" });
}
catch {
execSync("git config user.name 'release-chain'", { cwd });
execSync("git config user.email 'release-chain@users.noreply.github.com'", {
cwd,
});
}
}
function dependsOnPackage(pkg, packageName) {
const sections = [
"dependencies",
"devDependencies",
"peerDependencies",
"optionalDependencies",
];
return sections.some((section) => Boolean(pkg?.[section]?.[packageName]));
}
function throwOnMissing(message) {
throw new Error(message);
}
//# sourceMappingURL=index.js.map