yaba-release-cli
Version:
Yaba is a simple CLI tool that helps you manage releases of your Github projects.
464 lines (423 loc) • 16.2 kB
JavaScript
import { Octokit } from "octokit";
import supportsHyperlinks from "supports-hyperlinks";
import hyperlinker from "hyperlinker";
import kleur from "kleur";
import { spinner } from "./spinner.js";
import * as helper from "./helper.js";
import { mapGithubError } from "./errors.js";
import { exitCodes } from "./exit-codes.js";
const octokit = new Octokit({
auth: process.env.YABA_GITHUB_ACCESS_TOKEN
});
/**
* fetches all releases for a repository
*
* @param owner the owner of the repository
* @param repo the repository to fetch releases for
* @param limit max number of releases to return; 0 means all
* @returns {Promise<any[]>}
*/
export async function fetchReleases(owner, repo, limit = 5) {
const fetchAll = limit === 0;
spinner.start('Fetching releases...');
try {
let releases = [];
if (!fetchAll && limit <= 100) {
const { data } = await octokit.request('GET /repos/{owner}/{repo}/releases', {
owner, repo, per_page: limit
});
releases = data;
} else {
let page = 1;
while (true) {
const { data } = await octokit.request('GET /repos/{owner}/{repo}/releases', {
owner, repo, per_page: 100, page
});
releases = releases.concat(data);
if (data.length < 100) break;
if (!fetchAll && releases.length >= limit) {
releases = releases.slice(0, limit);
break;
}
page++;
}
}
spinner.succeed(`Found ${releases.length} release(s).`);
return releases;
} catch (error) {
spinner.fail('Failed to fetch releases.');
throw mapGithubError(error, 'Failed to fetch releases.');
}
}
/**
* fetches the last release
*
* @param owner the owner of the repository
* @param repo the repository to fetch last release for
* @returns {Promise<null|any>}
*/
export async function fetchLastRelease(owner, repo) {
spinner.start('Fetching the last release...');
try {
const { data: release } = await octokit.request('GET /repos/{owner}/{repo}/releases/latest', {
owner,
repo
});
spinner.succeed(`Last release: ${kleur.blue().bold().underline(release.tag_name)}`);
return release;
} catch (error) {
const status = error?.status || error?.response?.status;
if (status === 404) {
spinner.warn(`Last release not found.`);
return null;
}
spinner.fail('Could not fetch the latest release.');
throw mapGithubError(error, 'Could not fetch the latest release.');
}
}
/**
* fetches head branch name
*
* @param owner the owner of the repository
* @param repo the repository to fetch the head branch name
* @returns {Promise<string|null>}
*/
export async function fetchHeadBranch(owner, repo) {
spinner.start('Fetching head branch...');
try {
const { data: repository } = await octokit.request('GET /repos/{owner}/{repo}', {
owner,
repo
});
spinner.succeed(`Head branch: ${kleur.blue().bold().underline(repository.default_branch)}`);
return repository.default_branch;
} catch (error) {
spinner.fail('Could not fetch repository default branch.');
throw mapGithubError(error, 'Could not fetch repository default branch.');
}
}
/**
* if no release found, this will list all the commits in the head branch, otherwise will prepare the changelog
* between the latest release and the given head branch.
*
* @param owner the owner of the repository
* @param repo the repository to prepare changelog for
* @param head the head branch
* @param lastRelease the last release of the repo
* @returns {Promise<string[]>}
*/
export async function prepareChangeLog(owner, repo, head, lastRelease) {
return lastRelease === null
? await listCommits(owner, repo, head)
: await prepareChangelog(owner, repo, lastRelease.tag_name, head);
}
/**
* prepares changelog with the given parameters
*
* @param owner the owner of the repository
* @param repo the repository to prepare changelog for
* @param base the branch to compare with the {@code head}
* @param head the head branch of the {@code repo}
* @returns {Promise<string[]>}
*/
export async function prepareChangelog(owner, repo, base, head) {
spinner.start('Preparing the changelog....');
try {
const { data: changeLog } = await octokit.request('GET /repos/{owner}/{repo}/compare/{base}...{head}', {
owner,
repo,
base,
head
});
if (changeLog.commits.length !== 0) {
spinner.succeed('Changelog has been prepared...');
} else {
spinner.succeed(kleur.yellow().underline('Nothing found to release.'));
}
return changeLog.commits.map(item => item.commit.message);
} catch (error) {
const errorMessage = error?.response?.data?.message;
spinner.fail(`Something went wrong while preparing the changelog! ${errorMessage || ''}`.trim());
throw mapGithubError(error, 'Could not prepare changelog.');
}
}
/**
* fetches the commits from the {@code head} branch
* @param owner the owner of the repository
* @param repo the repository to fetch commits from
* @param head the head branch of the {@code repo}
* @returns {Promise<string[]>}
*/
export async function listCommits(owner, repo, head) {
spinner.start(`Fetching commits from ${head} reference...`);
try {
const { data: commits } = await octokit.request('GET /repos/{owner}/{repo}/commits', {
owner,
repo,
sha: head
});
spinner.succeed('Commits have been fetched...');
return commits.map(item => item.commit.message);
} catch (error) {
spinner.fail(`Could not fetch commits from ${head} reference.`);
throw mapGithubError(error, `Could not fetch commits from ${head} reference.`);
}
}
/**
* creates the release with the given parameters
*
* @param owner the owner of the repository
* @param repo the repository to create release for
* @param draft defines if release is a draft or not
* @param name the name/title of the release
* @param body the changelog to be defined in the release
* @param tag_name the tag name
* @param targetCommitish the target commitish
* @returns {Promise<string>}
*/
export async function createRelease(owner, repo, draft, name, body, tag_name, targetCommitish) {
try {
spinner.start('Preparing the release...');
const createReleasePayload = {
owner,
repo,
draft,
name: helper.releaseName(name),
body,
tag_name
};
if (typeof targetCommitish === 'string' && targetCommitish.trim().length > 0) {
createReleasePayload.target_commitish = targetCommitish.trim();
}
const { data: newRelease } = await octokit.request('POST /repos/{owner}/{repo}/releases', createReleasePayload);
const releaseUrl = prepareReleaseUrl(newRelease.html_url);
spinner.succeed(`Release has been prepared on Github. ${releaseUrl}`);
return newRelease.html_url;
} catch (error) {
const fallbackTagName = helper.promoteTagPrecisionToSeconds(createReleasePayload.tag_name);
if (isTagNameConflictError(error) && fallbackTagName && fallbackTagName !== createReleasePayload.tag_name) {
try {
spinner.warn(`Tag '${createReleasePayload.tag_name}' already exists. Retrying with '${fallbackTagName}'.`);
spinner.start('Retrying release preparation with second-precision tag...');
const retryPayload = {
owner,
repo,
draft,
name: helper.releaseName(name),
body,
tag_name: fallbackTagName
};
if (typeof targetCommitish === 'string' && targetCommitish.trim().length > 0) {
retryPayload.target_commitish = targetCommitish.trim();
}
const { data: newRelease } = await octokit.request('POST /repos/{owner}/{repo}/releases', retryPayload);
const releaseUrl = prepareReleaseUrl(newRelease.html_url);
spinner.succeed(`Release has been prepared on Github. ${releaseUrl}`);
return newRelease.html_url;
} catch (retryError) {
throw buildCreateReleaseError(retryError);
}
}
throw buildCreateReleaseError(error);
}
}
/**
* checks whether the given tag already exists in the repository.
*
* @param owner the repository owner
* @param repo the repository name
* @param tagName the tag name to check
* @returns {Promise<boolean>}
*/
export async function tagExists(owner, repo, tagName) {
try {
const { data: refs } = await octokit.request('GET /repos/{owner}/{repo}/git/matching-refs/{ref}', {
owner,
repo,
ref: `tags/${tagName}`
});
return Array.isArray(refs) && refs.some(item => item?.ref === `refs/tags/${tagName}`);
} catch (error) {
const status = error?.status || error?.response?.status;
if (status === 404) {
return false;
}
throw mapGithubError(error, `Could not verify whether tag '${tagName}' exists.`);
}
}
/**
* fetches pull request metadata by number.
*
* @param owner the repository owner
* @param repo the repository name
* @param pullNumber the pull request number
* @returns {Promise<{number: number, title: string, labels: string[]}>}
*/
export async function fetchPullRequestByNumber(owner, repo, pullNumber) {
try {
const { data: pullRequest } = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', {
owner,
repo,
pull_number: pullNumber
});
return {
number: pullRequest.number,
title: pullRequest.title,
labels: Array.isArray(pullRequest.labels)
? pullRequest.labels.map(item => item?.name).filter(Boolean)
: []
};
} catch (error) {
throw mapGithubError(error, `Could not fetch pull request #${pullNumber}.`);
}
}
/**
* validates and resolves a target commit-ish (branch, tag, or SHA) to a commit SHA.
*
* @param owner repository owner
* @param repo repository name
* @param ref target commit-ish
* @returns {Promise<string>}
*/
export async function resolveTargetCommitish(owner, repo, ref) {
spinner.start(`Validating target reference '${ref}'...`);
try {
const { data: commit } = await octokit.request('GET /repos/{owner}/{repo}/commits/{ref}', {
owner,
repo,
ref
});
spinner.succeed(`Target reference resolved to commit ${kleur.blue().bold().underline(commit.sha.substring(0, 12))}`);
return commit.sha;
} catch (error) {
spinner.fail(`Could not resolve target reference '${ref}'.`);
throw mapGithubError(error, `Could not resolve target reference '${ref}'.`);
}
}
/**
* retrieves the username with the help of the authentication token
* @returns {Promise<string>}
*/
export async function retrieveUsername() {
try {
const { data: user } = await octokit.request('GET /user');
return user.login;
} catch (error) {
throw mapGithubError(error, 'Could not retrieve authenticated user.');
}
}
/**
* fetches the combined commit status for a given ref using the legacy GitHub Status API.
* This covers status checks set by third-party integrations and older CI systems.
*
* @param owner the repository owner
* @param repo the repository name
* @param ref the commit SHA, branch, or tag to check
* @returns {Promise<{state: string, totalCount: number}>}
*/
export async function fetchCommitStatus(owner, repo, ref) {
try {
const { data } = await octokit.request('GET /repos/{owner}/{repo}/commits/{ref}/status', {
owner,
repo,
ref
});
return { state: data.state, totalCount: data.total_count };
} catch (error) {
throw mapGithubError(error, `Could not fetch commit status for ref '${ref}'.`);
}
}
/**
* fetches all GitHub Actions check runs for a given ref.
* This covers checks reported by GitHub Actions workflows and GitHub Apps.
*
* @param owner the repository owner
* @param repo the repository name
* @param ref the commit SHA, branch, or tag to check
* @returns {Promise<Array<{name: string, status: string, conclusion: string|null}>>}
*/
export async function fetchCommitCheckRuns(owner, repo, ref) {
try {
const { data } = await octokit.request('GET /repos/{owner}/{repo}/commits/{ref}/check-runs', {
owner,
repo,
ref
});
return data.check_runs.map(run => ({
name: run.name,
status: run.status,
conclusion: run.conclusion
}));
} catch (error) {
throw mapGithubError(error, `Could not fetch check runs for ref '${ref}'.`);
}
}
/**
* inspects auth context and optionally checks repository access when owner/repo are provided.
*
* @param owner optional repository owner for access check
* @param repo optional repository name for access check
* @returns {Promise<{login: string, oauthScopes: string, repoAccess: {checked: boolean, ok: boolean, owner?: string, repo?: string, status?: number|null, message?: string|null}}>}
*/
export async function inspectGithubAuth(owner, repo) {
try {
const userResponse = await octokit.request('GET /user');
const resolvedOwner = (typeof owner === 'string' && owner.trim())
? owner.trim()
: userResponse.data.login;
const diagnostics = {
login: userResponse.data.login,
oauthScopes: userResponse.headers?.['x-oauth-scopes'] || '',
repoAccess: {
checked: false,
ok: false
}
};
if (typeof repo === 'string' && repo.trim()) {
try {
await octokit.request('GET /repos/{owner}/{repo}', {
owner: resolvedOwner,
repo
});
diagnostics.repoAccess = {
checked: true,
ok: true,
owner: resolvedOwner,
repo: repo.trim()
};
} catch (error) {
diagnostics.repoAccess = {
checked: true,
ok: false,
owner: resolvedOwner,
repo: repo.trim(),
status: error?.status || error?.response?.status || null,
message: error?.response?.data?.message || error?.message || null
};
}
}
return diagnostics;
} catch (error) {
throw mapGithubError(error, 'Could not retrieve authenticated user.');
}
}
function isTagNameConflictError(error) {
const errors = error?.response?.data?.errors || [];
return errors.some(item => item?.field === 'tag_name' && item?.code === 'already_exists');
}
function buildCreateReleaseError(error) {
let errorMessage = "\n";
const errors = error?.response?.data?.errors || [];
const message = error?.response?.data?.message || 'Could not create release';
errors.forEach(element => {
errorMessage += `\t* field: '${element.field}' - code: '${element.code}'`;
});
spinner.fail(`${message} while preparing the release! ${errorMessage}`);
return mapGithubError(error, `${message} while preparing the release.`);
}
function prepareReleaseUrl(releaseUrl) {
if (supportsHyperlinks.stdout) {
return hyperlinker(`${kleur.blue().bold().underline(releaseUrl)}`);
}
return `${releaseUrl}`;
}