647 lines
18 KiB
JavaScript
647 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { spawnSync } = require("child_process");
|
|
|
|
const { findProjectRoot } = require("../lib/project-root");
|
|
const {
|
|
hasQualityChecklist,
|
|
normalizeRepoPath,
|
|
} = require("../lib/workflow-contract");
|
|
|
|
const REOPEN_COMMENT =
|
|
"Maintainer workflow refresh: closing and reopening to retrigger pull_request checks against the updated PR body.";
|
|
const DEFAULT_POLL_SECONDS = 20;
|
|
const BASE_BRANCH_MODIFIED_PATTERNS = [
|
|
/base branch was modified/i,
|
|
/base branch has been modified/i,
|
|
/branch was modified/i,
|
|
];
|
|
const REQUIRED_CHECKS = [
|
|
["pr-policy", ["pr-policy"]],
|
|
["source-validation", ["source-validation"]],
|
|
["artifact-preview", ["artifact-preview"]],
|
|
];
|
|
const SKILL_REVIEW_REQUIRED = ["review", "Skill Review & Optimize", "Skill Review & Optimize / review"];
|
|
|
|
function parseArgs(argv) {
|
|
const args = {
|
|
prs: null,
|
|
pollSeconds: DEFAULT_POLL_SECONDS,
|
|
dryRun: false,
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "--prs") {
|
|
args.prs = argv[index + 1] || null;
|
|
index += 1;
|
|
} else if (arg === "--poll-seconds") {
|
|
args.pollSeconds = Number(argv[index + 1]);
|
|
index += 1;
|
|
} else if (arg === "--dry-run") {
|
|
args.dryRun = true;
|
|
}
|
|
}
|
|
|
|
if (typeof args.pollSeconds !== "number" || Number.isNaN(args.pollSeconds) || args.pollSeconds <= 0) {
|
|
args.pollSeconds = DEFAULT_POLL_SECONDS;
|
|
}
|
|
|
|
return args;
|
|
}
|
|
|
|
function readJson(filePath) {
|
|
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
}
|
|
|
|
function readRepositorySlug(projectRoot) {
|
|
const packageJson = readJson(path.join(projectRoot, "package.json"));
|
|
const repository = packageJson.repository;
|
|
const rawUrl =
|
|
typeof repository === "string"
|
|
? repository
|
|
: repository && typeof repository.url === "string"
|
|
? repository.url
|
|
: null;
|
|
|
|
if (!rawUrl) {
|
|
throw new Error("package.json repository.url is required to resolve the GitHub slug.");
|
|
}
|
|
|
|
const match = rawUrl.match(/github\.com[:/](?<slug>[^/]+\/[^/]+?)(?:\.git)?$/i);
|
|
if (!match?.groups?.slug) {
|
|
throw new Error(`Could not derive a GitHub repo slug from repository url: ${rawUrl}`);
|
|
}
|
|
|
|
return match.groups.slug;
|
|
}
|
|
|
|
function runCommand(command, args, cwd, options = {}) {
|
|
const result = spawnSync(command, args, {
|
|
cwd,
|
|
encoding: "utf8",
|
|
input: options.input,
|
|
stdio: options.capture
|
|
? ["pipe", "pipe", "pipe"]
|
|
: options.input !== undefined
|
|
? ["pipe", "inherit", "inherit"]
|
|
: ["inherit", "inherit", "inherit"],
|
|
shell: process.platform === "win32",
|
|
});
|
|
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
|
|
if (typeof result.status !== "number" || result.status !== 0) {
|
|
const stderr = options.capture ? result.stderr.trim() : "";
|
|
throw new Error(stderr || `${command} ${args.join(" ")} failed with status ${result.status}`);
|
|
}
|
|
|
|
return options.capture ? result.stdout.trim() : "";
|
|
}
|
|
|
|
function runGhJson(projectRoot, args, options = {}) {
|
|
const stdout = runCommand(
|
|
"gh",
|
|
[...args, "--json", options.jsonFields || ""].filter(Boolean),
|
|
projectRoot,
|
|
{ capture: true, input: options.input },
|
|
);
|
|
return JSON.parse(stdout || "null");
|
|
}
|
|
|
|
function runGhApiJson(projectRoot, args, options = {}) {
|
|
const ghArgs = ["api", ...args];
|
|
if (options.paginate) {
|
|
ghArgs.push("--paginate");
|
|
}
|
|
if (options.slurp) {
|
|
ghArgs.push("--slurp");
|
|
}
|
|
const stdout = runCommand("gh", ghArgs, projectRoot, { capture: true, input: options.input });
|
|
return JSON.parse(stdout || "null");
|
|
}
|
|
|
|
function flattenGhSlurpPayload(payload) {
|
|
if (!Array.isArray(payload)) {
|
|
return [];
|
|
}
|
|
|
|
const flattened = [];
|
|
for (const page of payload) {
|
|
if (Array.isArray(page)) {
|
|
flattened.push(...page);
|
|
} else if (page && typeof page === "object") {
|
|
flattened.push(page);
|
|
}
|
|
}
|
|
return flattened;
|
|
}
|
|
|
|
function ensureOnMainAndClean(projectRoot) {
|
|
const branch = runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], projectRoot, {
|
|
capture: true,
|
|
});
|
|
if (branch !== "main") {
|
|
throw new Error(`merge-batch must run from main. Current branch: ${branch}`);
|
|
}
|
|
|
|
const status = runCommand(
|
|
"git",
|
|
["status", "--porcelain", "--untracked-files=no"],
|
|
projectRoot,
|
|
{ capture: true },
|
|
);
|
|
if (status) {
|
|
throw new Error("merge-batch requires a clean tracked working tree before starting.");
|
|
}
|
|
}
|
|
|
|
function parsePrList(prs) {
|
|
if (!prs) {
|
|
throw new Error("Usage: merge_batch.cjs --prs 450,449,446,451");
|
|
}
|
|
|
|
const parsed = prs
|
|
.split(/[\s,]+/)
|
|
.map((value) => Number.parseInt(value, 10))
|
|
.filter((value) => Number.isInteger(value) && value > 0);
|
|
|
|
if (!parsed.length) {
|
|
throw new Error("No valid PR numbers were provided.");
|
|
}
|
|
|
|
return [...new Set(parsed)];
|
|
}
|
|
|
|
function extractSummaryBlock(body) {
|
|
const text = String(body || "").replace(/\r\n/g, "\n").trim();
|
|
if (!text) {
|
|
return "";
|
|
}
|
|
|
|
const sectionMatch = text.match(/^\s*##\s+/m);
|
|
if (!sectionMatch) {
|
|
return text;
|
|
}
|
|
|
|
const prefix = text.slice(0, sectionMatch.index).trimEnd();
|
|
return prefix;
|
|
}
|
|
|
|
function extractTemplateSections(templateContent) {
|
|
const text = String(templateContent || "").replace(/\r\n/g, "\n").trim();
|
|
const sectionMatch = text.match(/^\s*##\s+/m);
|
|
if (!sectionMatch) {
|
|
return text;
|
|
}
|
|
|
|
return text.slice(sectionMatch.index).trim();
|
|
}
|
|
|
|
function normalizePrBody(body, templateContent) {
|
|
const summary = extractSummaryBlock(body);
|
|
const templateSections = extractTemplateSections(templateContent);
|
|
|
|
if (!summary) {
|
|
return templateSections;
|
|
}
|
|
|
|
return `${summary}\n\n${templateSections}`.trim();
|
|
}
|
|
|
|
function loadPullRequestTemplate(projectRoot) {
|
|
return fs.readFileSync(path.join(projectRoot, ".github", "PULL_REQUEST_TEMPLATE.md"), "utf8");
|
|
}
|
|
|
|
function loadPullRequestDetails(projectRoot, repoSlug, prNumber) {
|
|
const details = runGhJson(projectRoot, ["pr", "view", String(prNumber)], {
|
|
jsonFields: [
|
|
"body",
|
|
"mergeStateStatus",
|
|
"mergeable",
|
|
"number",
|
|
"title",
|
|
"headRefOid",
|
|
"url",
|
|
].join(","),
|
|
});
|
|
|
|
const filesPayload = runGhApiJson(projectRoot, [
|
|
`repos/${repoSlug}/pulls/${prNumber}/files?per_page=100`,
|
|
], {
|
|
paginate: true,
|
|
slurp: true,
|
|
});
|
|
|
|
const files = flattenGhSlurpPayload(filesPayload)
|
|
.map((entry) => normalizeRepoPath(entry?.filename))
|
|
.filter(Boolean);
|
|
|
|
return {
|
|
...details,
|
|
files,
|
|
hasSkillChanges: files.some((filePath) => filePath.endsWith("/SKILL.md") || filePath === "SKILL.md"),
|
|
};
|
|
}
|
|
|
|
function needsBodyRefresh(prDetails) {
|
|
return !hasQualityChecklist(prDetails.body);
|
|
}
|
|
|
|
function getRequiredCheckAliases(prDetails) {
|
|
const aliases = REQUIRED_CHECKS.map(([, value]) => value);
|
|
if (prDetails.hasSkillChanges) {
|
|
aliases.push(SKILL_REVIEW_REQUIRED);
|
|
}
|
|
return aliases;
|
|
}
|
|
|
|
function mergeableIsConflict(prDetails) {
|
|
const mergeable = String(prDetails.mergeable || "").toUpperCase();
|
|
const mergeState = String(prDetails.mergeStateStatus || "").toUpperCase();
|
|
return mergeable === "CONFLICTING" || mergeState === "DIRTY";
|
|
}
|
|
|
|
function selectLatestCheckRuns(checkRuns) {
|
|
const byName = new Map();
|
|
|
|
for (const run of checkRuns) {
|
|
const name = String(run?.name || "");
|
|
if (!name) {
|
|
continue;
|
|
}
|
|
|
|
const previous = byName.get(name);
|
|
if (!previous) {
|
|
byName.set(name, run);
|
|
continue;
|
|
}
|
|
|
|
const currentKey = run.completed_at || run.started_at || run.created_at || "";
|
|
const previousKey = previous.completed_at || previous.started_at || previous.created_at || "";
|
|
|
|
if (currentKey > previousKey || (currentKey === previousKey && Number(run.id || 0) > Number(previous.id || 0))) {
|
|
byName.set(name, run);
|
|
}
|
|
}
|
|
|
|
return byName;
|
|
}
|
|
|
|
function checkRunMatchesAliases(checkRun, aliases) {
|
|
const name = String(checkRun?.name || "");
|
|
return aliases.some((alias) => name === alias || name.endsWith(` / ${alias}`));
|
|
}
|
|
|
|
function summarizeRequiredCheckRuns(checkRuns, requiredAliases) {
|
|
const latestByName = selectLatestCheckRuns(checkRuns);
|
|
const summaries = [];
|
|
|
|
for (const aliases of requiredAliases) {
|
|
const latestRun = [...latestByName.values()].find((run) => checkRunMatchesAliases(run, aliases));
|
|
const label = aliases[0];
|
|
|
|
if (!latestRun) {
|
|
summaries.push({ label, state: "missing", conclusion: null, run: null });
|
|
continue;
|
|
}
|
|
|
|
const status = String(latestRun.status || "").toLowerCase();
|
|
const conclusion = String(latestRun.conclusion || "").toLowerCase();
|
|
if (status !== "completed") {
|
|
summaries.push({ label, state: "pending", conclusion, run: latestRun });
|
|
continue;
|
|
}
|
|
|
|
if (["success", "neutral", "skipped"].includes(conclusion)) {
|
|
summaries.push({ label, state: "success", conclusion, run: latestRun });
|
|
continue;
|
|
}
|
|
|
|
summaries.push({ label, state: "failed", conclusion, run: latestRun });
|
|
}
|
|
|
|
return summaries;
|
|
}
|
|
|
|
function formatCheckSummary(summaries) {
|
|
return summaries
|
|
.map((summary) => {
|
|
if (summary.state === "success") {
|
|
return `${summary.label}: ${summary.conclusion || "success"}`;
|
|
}
|
|
if (summary.state === "pending") {
|
|
return `${summary.label}: pending (${summary.conclusion || "in progress"})`;
|
|
}
|
|
if (summary.state === "failed") {
|
|
return `${summary.label}: failed (${summary.conclusion || "unknown"})`;
|
|
}
|
|
return `${summary.label}: missing`;
|
|
})
|
|
.join(", ");
|
|
}
|
|
|
|
function getHeadSha(projectRoot, repoSlug, prNumber) {
|
|
const details = runGhJson(projectRoot, ["pr", "view", String(prNumber)], {
|
|
jsonFields: "headRefOid",
|
|
});
|
|
return details.headRefOid;
|
|
}
|
|
|
|
function listActionRequiredRuns(projectRoot, repoSlug, headSha) {
|
|
const payload = runGhApiJson(projectRoot, [
|
|
`repos/${repoSlug}/actions/runs?head_sha=${headSha}&status=action_required&per_page=100`,
|
|
], {
|
|
paginate: true,
|
|
slurp: true,
|
|
});
|
|
|
|
const runs = flattenGhSlurpPayload(payload).filter((run) => Number.isInteger(Number(run?.id)));
|
|
const seen = new Set();
|
|
return runs.filter((run) => {
|
|
const id = Number(run.id);
|
|
if (seen.has(id)) {
|
|
return false;
|
|
}
|
|
seen.add(id);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function approveActionRequiredRuns(projectRoot, repoSlug, headSha) {
|
|
const runs = listActionRequiredRuns(projectRoot, repoSlug, headSha);
|
|
for (const run of runs) {
|
|
runCommand(
|
|
"gh",
|
|
["api", "-X", "POST", `repos/${repoSlug}/actions/runs/${run.id}/approve`],
|
|
projectRoot,
|
|
);
|
|
}
|
|
return runs;
|
|
}
|
|
|
|
function listCheckRuns(projectRoot, repoSlug, headSha) {
|
|
const payload = runGhApiJson(projectRoot, [
|
|
`repos/${repoSlug}/commits/${headSha}/check-runs?per_page=100`,
|
|
]);
|
|
return Array.isArray(payload?.check_runs) ? payload.check_runs : [];
|
|
}
|
|
|
|
async function waitForRequiredChecks(
|
|
projectRoot,
|
|
repoSlug,
|
|
headSha,
|
|
requiredAliases,
|
|
pollSeconds,
|
|
maxAttempts = 180,
|
|
) {
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
const checkRuns = listCheckRuns(projectRoot, repoSlug, headSha);
|
|
const summaries = summarizeRequiredCheckRuns(checkRuns, requiredAliases);
|
|
const pending = summaries.filter((summary) => summary.state === "pending" || summary.state === "missing");
|
|
const failed = summaries.filter((summary) => summary.state === "failed");
|
|
|
|
console.log(`[merge-batch] Checks for ${headSha}: ${formatCheckSummary(summaries)}`);
|
|
|
|
if (failed.length) {
|
|
throw new Error(
|
|
`Required checks failed for ${headSha}: ${failed.map((item) => `${item.label} (${item.conclusion || "failed"})`).join(", ")}`,
|
|
);
|
|
}
|
|
|
|
if (!pending.length) {
|
|
return summaries;
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, pollSeconds * 1000));
|
|
}
|
|
|
|
throw new Error(`Timed out waiting for required checks on ${headSha}.`);
|
|
}
|
|
|
|
function patchPrBody(projectRoot, repoSlug, prNumber, body) {
|
|
const payload = JSON.stringify({ body });
|
|
runCommand(
|
|
"gh",
|
|
["api", `repos/${repoSlug}/pulls/${prNumber}`, "-X", "PATCH", "--input", "-"],
|
|
projectRoot,
|
|
{ input: payload },
|
|
);
|
|
}
|
|
|
|
function closeAndReopenPr(projectRoot, prNumber) {
|
|
runCommand("gh", ["pr", "close", String(prNumber), "--comment", REOPEN_COMMENT], projectRoot);
|
|
runCommand("gh", ["pr", "reopen", String(prNumber)], projectRoot);
|
|
}
|
|
|
|
function isRetryableMergeError(error) {
|
|
const message = String(error?.message || error || "");
|
|
return BASE_BRANCH_MODIFIED_PATTERNS.some((pattern) => pattern.test(message));
|
|
}
|
|
|
|
function gitCheckoutMain(projectRoot) {
|
|
runCommand("git", ["checkout", "main"], projectRoot);
|
|
}
|
|
|
|
function gitPullMain(projectRoot) {
|
|
runCommand("git", ["pull", "--ff-only", "origin", "main"], projectRoot);
|
|
}
|
|
|
|
function syncContributors(projectRoot) {
|
|
runCommand("npm", ["run", "sync:contributors"], projectRoot);
|
|
}
|
|
|
|
function commitAndPushReadmeIfChanged(projectRoot) {
|
|
const status = runCommand("git", ["status", "--porcelain", "--untracked-files=no"], projectRoot, {
|
|
capture: true,
|
|
});
|
|
|
|
if (!status) {
|
|
return { changed: false };
|
|
}
|
|
|
|
const lines = status.split(/\r?\n/).filter(Boolean);
|
|
const unexpected = lines.filter((line) => !line.includes("README.md"));
|
|
if (unexpected.length) {
|
|
throw new Error(`merge-batch expected sync:contributors to touch README.md only. Unexpected drift: ${unexpected.join(", ")}`);
|
|
}
|
|
|
|
runCommand("git", ["add", "README.md"], projectRoot);
|
|
const staged = runCommand("git", ["diff", "--cached", "--name-only"], projectRoot, { capture: true });
|
|
if (!staged.includes("README.md")) {
|
|
return { changed: false };
|
|
}
|
|
|
|
runCommand("git", ["commit", "-m", "chore: sync contributor credits after merge batch"], projectRoot);
|
|
runCommand("git", ["push", "origin", "main"], projectRoot);
|
|
return { changed: true };
|
|
}
|
|
|
|
async function mergePullRequest(projectRoot, repoSlug, prNumber, options) {
|
|
const template = loadPullRequestTemplate(projectRoot);
|
|
let prDetails = loadPullRequestDetails(projectRoot, repoSlug, prNumber);
|
|
|
|
console.log(`[merge-batch] PR #${prNumber}: ${prDetails.title}`);
|
|
|
|
if (mergeableIsConflict(prDetails)) {
|
|
throw new Error(`PR #${prNumber} is in conflict state; resolve conflicts on the PR branch before merging.`);
|
|
}
|
|
|
|
let bodyRefreshed = false;
|
|
if (needsBodyRefresh(prDetails)) {
|
|
const normalizedBody = normalizePrBody(prDetails.body, template);
|
|
if (!options.dryRun) {
|
|
patchPrBody(projectRoot, repoSlug, prNumber, normalizedBody);
|
|
closeAndReopenPr(projectRoot, prNumber);
|
|
}
|
|
bodyRefreshed = true;
|
|
console.log(`[merge-batch] PR #${prNumber}: refreshed PR body and retriggered checks.`);
|
|
prDetails = loadPullRequestDetails(projectRoot, repoSlug, prNumber);
|
|
}
|
|
|
|
const headSha = getHeadSha(projectRoot, repoSlug, prNumber);
|
|
const approvedRuns = options.dryRun ? [] : approveActionRequiredRuns(projectRoot, repoSlug, headSha);
|
|
if (approvedRuns.length) {
|
|
console.log(
|
|
`[merge-batch] PR #${prNumber}: approved ${approvedRuns.length} fork run(s) waiting on action_required.`,
|
|
);
|
|
}
|
|
|
|
const requiredCheckAliases = getRequiredCheckAliases(prDetails);
|
|
if (!options.dryRun) {
|
|
await waitForRequiredChecks(projectRoot, repoSlug, headSha, requiredCheckAliases, options.pollSeconds);
|
|
}
|
|
|
|
if (options.dryRun) {
|
|
console.log(`[merge-batch] PR #${prNumber}: dry run complete, skipping merge and post-merge sync.`);
|
|
return {
|
|
prNumber,
|
|
bodyRefreshed,
|
|
merged: false,
|
|
approvedRuns: [],
|
|
followUp: { changed: false },
|
|
};
|
|
}
|
|
|
|
let merged = false;
|
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
try {
|
|
if (!options.dryRun) {
|
|
runCommand("gh", ["pr", "merge", String(prNumber), "--squash"], projectRoot);
|
|
}
|
|
merged = true;
|
|
break;
|
|
} catch (error) {
|
|
if (!isRetryableMergeError(error) || attempt === 3) {
|
|
throw error;
|
|
}
|
|
|
|
console.log(`[merge-batch] PR #${prNumber}: base branch changed, refreshing main and retrying merge.`);
|
|
gitCheckoutMain(projectRoot);
|
|
gitPullMain(projectRoot);
|
|
prDetails = loadPullRequestDetails(projectRoot, repoSlug, prNumber);
|
|
const refreshedSha = prDetails.headRefOid || headSha;
|
|
if (!options.dryRun) {
|
|
await waitForRequiredChecks(projectRoot, repoSlug, refreshedSha, requiredCheckAliases, options.pollSeconds);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!merged) {
|
|
throw new Error(`Failed to merge PR #${prNumber}.`);
|
|
}
|
|
|
|
console.log(`[merge-batch] PR #${prNumber}: merged.`);
|
|
|
|
gitCheckoutMain(projectRoot);
|
|
gitPullMain(projectRoot);
|
|
syncContributors(projectRoot);
|
|
|
|
const followUp = commitAndPushReadmeIfChanged(projectRoot);
|
|
if (followUp.changed) {
|
|
console.log(`[merge-batch] PR #${prNumber}: README follow-up committed and pushed.`);
|
|
}
|
|
|
|
return {
|
|
prNumber,
|
|
bodyRefreshed,
|
|
merged,
|
|
approvedRuns: approvedRuns.map((run) => run.id),
|
|
followUp,
|
|
};
|
|
}
|
|
|
|
async function runBatch(projectRoot, prNumbers, options = {}) {
|
|
const repoSlug = readRepositorySlug(projectRoot);
|
|
const results = [];
|
|
|
|
ensureOnMainAndClean(projectRoot);
|
|
|
|
for (const prNumber of prNumbers) {
|
|
const result = await mergePullRequest(projectRoot, repoSlug, prNumber, options);
|
|
results.push(result);
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const projectRoot = findProjectRoot(__dirname);
|
|
const prNumbers = parsePrList(args.prs);
|
|
|
|
if (args.dryRun) {
|
|
console.log(`[merge-batch] Dry run for PRs: ${prNumbers.join(", ")}`);
|
|
}
|
|
|
|
const results = await runBatch(projectRoot, prNumbers, {
|
|
dryRun: args.dryRun,
|
|
pollSeconds: args.pollSeconds,
|
|
});
|
|
|
|
console.log(
|
|
`[merge-batch] Completed ${results.length} PR(s): ${results.map((result) => `#${result.prNumber}`).join(", ")}`,
|
|
);
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch((error) => {
|
|
console.error(`[merge-batch] ${error.message}`);
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
approveActionRequiredRuns,
|
|
baseBranchModifiedPatterns: BASE_BRANCH_MODIFIED_PATTERNS,
|
|
checkRunMatchesAliases,
|
|
closeAndReopenPr,
|
|
commitAndPushReadmeIfChanged,
|
|
ensureOnMainAndClean,
|
|
extractSummaryBlock,
|
|
extractTemplateSections,
|
|
formatCheckSummary,
|
|
getRequiredCheckAliases,
|
|
gitCheckoutMain,
|
|
gitPullMain,
|
|
isRetryableMergeError,
|
|
listActionRequiredRuns,
|
|
listCheckRuns,
|
|
loadPullRequestDetails,
|
|
loadPullRequestTemplate,
|
|
mergePullRequest,
|
|
mergeableIsConflict,
|
|
normalizePrBody,
|
|
parseArgs,
|
|
parsePrList,
|
|
readRepositorySlug,
|
|
runBatch,
|
|
selectLatestCheckRuns,
|
|
summarizeRequiredCheckRuns,
|
|
waitForRequiredChecks,
|
|
};
|