diff --git a/config/auth.js b/config/auth.js new file mode 100644 index 0000000..2122a67 --- /dev/null +++ b/config/auth.js @@ -0,0 +1,17 @@ +/** + * Creates an authenticated Octokit instance for a given GitHub App installation. + * This function performs asynchronous operations to obtain the installation ID and + * then returns a Promise that resolves to an authenticated Octokit instance for that installation. + * + * @param {App} ghApp - The GitHub App instance. + * @param {string} owner - The owner of the repository. + * @param {string} repo - The repository name. + * @returns {Promise} A Promise that resolves to an authenticated Octokit instance. + */ +export const getOcktokitClient = async (ghApp, owner, repo) => { + const { data: installation } = await ghApp.octokit.request( + `GET /repos/{owner}/{repo}/installation`, + { owner, repo } + ); + return ghApp.getInstallationOctokit(installation.id); +}; diff --git a/crud/pullRequestComments.crud.js b/crud/pullRequestComments.crud.js new file mode 100644 index 0000000..f84bb1f --- /dev/null +++ b/crud/pullRequestComments.crud.js @@ -0,0 +1,57 @@ + +/** + * Generates a comment string for a given error object based on its properties. + * + * @param {Error} errorInput - Error object that determines the comment to be posted. + * @returns {string|null} - A formatted comment string if the error type merits a comment in the PR; otherwise, null. + * + */ +export const getErrorComment = (errorInput, formatErrorMessage) => { + if (errorInput.shouldResultInPRComment) { + return formatErrorMessage(errorInput); + } + return null; +}; + +/** + * Posts a comment to a GitHub pull request based on the error type using Octokit instance. + * + * @param {InstanceType} octokit - An Octokit instance initialized with a GitHub token. + * @param {string} owner - Owner of the repository. + * @param {string} repo - Repository name. + * @param {number} prNumber - Pull request number. + * @param {Error} errorInput - Error object that determines the comment to be posted. + * @param {Function} getErrorComment - Function that generates a comment string for a given error object based on its properties. + */ +export const postPRComment = async ( + octokit, + owner, + repo, + prNumber, + errorInput, + getErrorComment, + formatErrorMessage +) => { + const comment = getErrorComment(errorInput, formatErrorMessage); + + if (comment) { + try { + // Post a comment to the pull request + await octokit.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: comment, + }); + console.log(`Comment posted to PR #${prNumber}: "${comment}"`); + } catch (error) { + console.error( + `Error posting comment to PR #${prNumber}: ${error.message}` + ); + } + } else { + console.log( + `No comment posted to PR #${prNumber} due to error type: ${errorInput.name}` + ); + } +}; diff --git a/crud/pullRequestDescription.crud..js b/crud/pullRequestDescription.crud..js new file mode 100644 index 0000000..692b1b5 --- /dev/null +++ b/crud/pullRequestDescription.crud..js @@ -0,0 +1,32 @@ +import { PullRequestDataExtractionError } from "./customErrors.js"; + +/** + * Extracts relevant data from a GitHub Pull Request from the GitHub action context. + * + * @returns {Object} Object containing pull request details. + * @throws {PullRequestDataExtractionError} If data extraction fails. + */ +export const extractPullRequestData = (pr_payload) => { + try { + console.log( + `Extracting data for PR #${pr_payload.number} in ${pr_payload.base.repo.owner.login}/${pr_payload.base.repo.name}` + ); + + // Return relevant PR data including user's username + return { + owner: pr_payload.base.repo.owner.login, + repo: pr_payload.base.repo.name, + branchRef: pr_payload.base.ref, + prOwner: pr_payload.head.repo.owner.login, + prRepo: pr_payload.head.repo.name, + prBranchRef: pr_payload.head.ref, + prNumber: pr_payload.number, + prDescription: pr_payload.body, + prLink: pr_payload.html_url, + }; + } catch (error) { + console.error(`Error extracting data from pull request: ${error.message}`); + // Throw a custom error for issues during data extraction + throw new PullRequestDataExtractionError(); + } +}; diff --git a/crud/pullRequestFiles.crud.js b/crud/pullRequestFiles.crud.js new file mode 100644 index 0000000..070679a --- /dev/null +++ b/crud/pullRequestFiles.crud.js @@ -0,0 +1,126 @@ +import { Octokit } from "octokit"; + +import { + GetGithubContentError, + CreateFileError, + UpdateFileError, + DeleteFileError +} from "./customErrors.js"; + +/** + * Creates or updates a file in a GitHub repository using Octokit instance. + * + * @param {InstanceType} octokit - An Octokit instance initialized with a GitHub token. + * @param {string} owner - Owner of the repository. + * @param {string} repo - Repository name. + * @param {string} path - File path within the repository. + * @param {string} content - Base64 encoded content to be written to the file. + * @param {string} message - Commit message. + * @param {string} branchRef - Branch reference for the commit. + * @throws {ChangesetFileAccessError} If access to the file fails. + */ +export const createOrUpdateFile = async ( + octokit, + owner, + repo, + branchRef, + prNumber, + path, + content +) => { + // File's SHA to check if file exists + let sha, message; + // Attempt to retrieve the file's SHA to check if it exists + try { + const { data } = await octokit.rest.repos.getContent({ + owner, + repo, + path, + ref: branchRef, + }); + sha = data?.sha; + message = `${ + sha ? "update" : "create" + } changeset file ${prNumber}.yml for PR #${prNumber}`; + } catch (error) { + if (error.status === 404) { + console.log("Changeset file not found. Proceeding to create a new one."); + } else { + throw new GetGithubContentError(); + } + } + + // Create or update the changeset file content + try { + await octokit.rest.repos.createOrUpdateFileContents({ + owner, + repo, + path, + message, + content, + sha, // If file exists, sha is used to update; otherwise, file is created + branch: branchRef, + }); + console.log(`File: ${path} ${sha ? "updated" : "created"} successfully.`); + } catch (error) { + if (!sha) { + throw new CreateFileError(); + } else { + throw new UpdateFileError(); + } + } +}; + +/** + * Deletes a file in a GitHub repository using an Octokit instance. + * + * @param {InstanceType} octokit - An Octokit instance initialized with a GitHub token. + * @param {string} owner - Owner of the repository. + * @param {string} repo - Repository name. + * @param {string} path - File path within the repository. + * @param {string} message - Commit message. + * @param {string} branchRef - Branch reference for the commit. + * @throws {GetGithubContentError} If retrieving the file content fails. + * @throws {DeleteFileError} If deleting the file fails. + */ +export const deleteFile = async ( + octokit, + owner, + repo, + path, + message, + branchRef +) => { + let sha; + + // Retrieve the file's SHA to confirm existence + try { + const { data } = await octokit.rest.repos.getContent({ + owner, + repo, + path, + ref: branchRef, + }); + sha = data?.sha; + message = `${ + sha ? "update" : "create" + } changeset file ${prNumber}.yml for PR #${prNumber}`; + } catch (error) { + throw new GetGithubContentError(); + } + + // Delete the file using its SHA + try { + await octokit.rest.repos.deleteFile({ + owner, + repo, + path, + message, + sha, + branch: branchRef, + }); + console.log(`File: ${path} deleted successfully.`); + } catch (error) { + throw new DeleteFileError(); + } +}; diff --git a/crud/pullRequestLabels.crud.js b/crud/pullRequestLabels.crud.js new file mode 100644 index 0000000..959b3cf --- /dev/null +++ b/crud/pullRequestLabels.crud.js @@ -0,0 +1,107 @@ +import { + CategoryWithSkipOptionError, + UpdatePRLabelError, +} from "./customErrors.js"; +import { SKIP_LABEL } from "../config/constants.js"; + +/** + * Adds or removes a label from a GitHub pull request using Octokit instance. + * + * @param {InstanceType} octokit - An Octokit instance initialized with a GitHub token. + * @param {string} owner - Owner of the repository. + * @param {string} repo - Repository name. + * @param {number} prNumber - Pull request number. + * @param {string} label - Label to be added or removed. + * @param {boolean} addLabel - Flag to add or remove the label. + * @throws {UpdatePRLabelError} If unable to add or remove label. + */ +export const updatePRLabel = async ( + octokit, + owner, + repo, + prNumber, + label, + addLabel +) => { + try { + // Get the current labels on the pull request + const { data: currentLabels } = await octokit.rest.issues.listLabelsOnIssue( + { + owner, + repo, + issue_number: prNumber, + } + ); + + // Check to see if the label is already on the pull request + const labelExists = currentLabels.some((element) => element.name === label); + + if (addLabel && !labelExists) { + // Add the label to the pull request + await octokit.rest.issues.addLabels({ + owner, + repo, + issue_number: prNumber, + labels: [label], + }); + console.log(`Label "${label}" added to PR #${prNumber}`); + } else if (!addLabel && labelExists) { + // Remove the label from the pull request + await octokit.rest.issues.removeLabel({ + owner, + repo, + issue_number: prNumber, + name: label, + }); + console.log(`Label "${label}" removed from PR #${prNumber}`); + } else { + console.log( + `Label "${label}" is already ${ + addLabel ? "present" : "absent" + } on PR #${prNumber}. No action taken.` + ); + } + } catch (error) { + console.error( + `Error updating label "${label}" for PR #${prNumber}: ${error.message}` + ); + throw new UpdatePRLabelError(); + } +}; + +/** + * Handles a changeset entry map that contains the "skip" option. + * + * @param {InstanceType} octokit - An Octokit instance initialized with a GitHub token. + * @param {Object} entryMap - Map of changeset entries. + * @param {string} owner - Owner of the repository. + * @param {string} repo - Repository name. + * @param {number} prNumber - Pull request number. + * @param {Function} updateLabel - Function to add or remove a label from a PR. + * @throws {CategoryWithSkipOptionError} If 'skip' and other entries are present. + */ +export const handleSkipOption = async ( + octokit, + entryMap, + owner, + repo, + prNumber, + updateLabel +) => { + if (entryMap && Object.keys(entryMap).includes("skip")) { + // Check if "skip" is the only prefix in the changeset entries + if (Object.keys(entryMap).length > 1) { + throw new CategoryWithSkipOptionError(); + } else { + console.log("No changeset file created or updated."); + // Adds "skip-changelog" label in PR if not present + await updateLabel(octokit, owner, repo, prNumber, SKIP_LABEL, true); + // Indicates to index.js that the program should exit without creating or updating the changeset file + return true; + } + } + // Removes "skip-changelog" label in PR if present + await updateLabel(octokit, owner, repo, prNumber, SKIP_LABEL, false); + // Indicates to index.js that the program should proceed with creating or updating the changeset file + return false; +}; diff --git a/server.js b/server.js index 87e7f1c..7e2fb22 100644 --- a/server.js +++ b/server.js @@ -5,7 +5,7 @@ import dotenv from "dotenv"; import express from "express"; import fs from "fs"; -import { Octokit, App } from "octokit"; +import { App } from "octokit"; import { createNodeMiddleware } from "@octokit/webhooks"; import setupWebhooks from "./config/webhooks.js"; diff --git a/utils/customErrors.js b/utils/customErrors.js index fe929f3..66ebebe 100644 --- a/utils/customErrors.js +++ b/utils/customErrors.js @@ -39,14 +39,14 @@ export class GetGithubContentError extends Error { } /** - * Represents an error during the creation of a changeset file in a GitHub repository. + * Represents an error during the creation of a file in a GitHub repository. */ -export class CreateChangesetFileError extends Error { +export class CreateFileError extends Error { /** * Constructs the CreateChangesetFileError instance. - * @param {string} [message="Error creating changeset file"] - Custom error message. + * @param {string} [message="Error creating file in repository"] - Custom error message. */ - constructor(message = "Error creating changeset file") { + constructor(message = "Error creating file in repository") { super(message); this.name = this.constructor.name; /** @@ -58,14 +58,14 @@ export class CreateChangesetFileError extends Error { } /** - * Represents an error during the update of a changeset file in a GitHub repository. + * Represents an error during the update of a file in a GitHub repository. */ -export class UpdateChangesetFileError extends Error { +export class UpdateFileError extends Error { /** - * Constructs the UpdateChangesetFileError instance. - * @param {string} [message="Error updating changeset file"] - Custom error message. + * Constructs the UpdateFileError instance. + * @param {string} [message="Error updating file in repository"] - Custom error message. */ - constructor(message = "Error updating changeset file") { + constructor(message = "Error updating file in repository") { super(message); this.name = this.constructor.name; /** @@ -76,6 +76,26 @@ export class UpdateChangesetFileError extends Error { } } +/** + * Represents an error during the deletion of a file in a GitHub repository. + */ +export class DeleteFileError extends Error { + /** + * Constructs the DeleteFileError instance. + * @param {string} [message="Error deleting file in repository"] - Custom error message. + */ + constructor(message = "Error deleting file in repository") { + super(message); + this.name = this.constructor.name; + /** + * Indicates whether this error should trigger a comment in the pull request. + * @type {boolean} + */ + this.shouldResultInPRComment = false; + } +} + + /** * Represents an error that occurs when updating the label of a pull request. */ diff --git a/utils/githubUtils.js b/utils/githubUtils.js index 0913d63..35eceea 100644 --- a/utils/githubUtils.js +++ b/utils/githubUtils.js @@ -3,8 +3,9 @@ import { Octokit } from "octokit"; import { PullRequestDataExtractionError, GetGithubContentError, - CreateChangesetFileError, - UpdateChangesetFileError, + CreateFileError, + UpdateFileError, + DeleteFileError, CategoryWithSkipOptionError, UpdatePRLabelError, } from "./customErrors.js"; @@ -224,17 +225,22 @@ export const createOrUpdateFile = async ( // File's SHA to check if file exists let sha, message; // Attempt to retrieve the file's SHA to check if it exists + console.log("------------------------------------"); + console.log("owner", owner); + console.log("repo", repo); + console.log("prNumber", prNumber); + console.log("branchRef", branchRef); + console.log("path", path); + console.log("content", content); + console.log("------------------------------------"); try { - const response = await octokit.rest.repos.getContent({ + const { data } = await octokit.rest.repos.getContent({ owner, repo, path, ref: branchRef, }); - sha = response.data.sha; - message = `${ - sha ? "update" : "create" - } changeset file ${prNumber}.yml for PR #${prNumber}`; + sha = data?.sha; } catch (error) { if (error.status === 404) { console.log("Changeset file not found. Proceeding to create a new one."); @@ -242,7 +248,9 @@ export const createOrUpdateFile = async ( throw new GetGithubContentError(); } } - + message = `${ + sha ? "update" : "create" + } changeset file ${prNumber}.yml for PR #${prNumber}`; // Create or update the changeset file content try { await octokit.rest.repos.createOrUpdateFileContents({ @@ -256,14 +264,69 @@ export const createOrUpdateFile = async ( }); console.log(`File: ${path} ${sha ? "updated" : "created"} successfully.`); } catch (error) { + console.log(error); if (!sha) { - throw new CreateChangesetFileError(); + throw new CreateFileError(); } else { - throw new UpdateChangesetFileError(); + throw new UpdateFileError(); } } }; +/** + * Deletes a file in a GitHub repository using an Octokit instance. + * + * @param {InstanceType} octokit - An Octokit instance initialized with a GitHub token. + * @param {string} owner - Owner of the repository. + * @param {string} repo - Repository name. + * @param {string} path - File path within the repository. + * @param {string} message - Commit message. + * @param {string} branchRef - Branch reference for the commit. + * @throws {GetGithubContentError} If retrieving the file content fails. + * @throws {DeleteFileError} If deleting the file fails. + */ +export const deleteFile = async ( + octokit, + owner, + repo, + path, + message, + branchRef +) => { + let sha; + + // Retrieve the file's SHA to confirm existence + try { + const { data } = await octokit.rest.repos.getContent({ + owner, + repo, + path, + ref: branchRef, + }); + sha = data?.sha; + message = `${ + sha ? "update" : "create" + } changeset file ${prNumber}.yml for PR #${prNumber}`; + } catch (error) { + throw new GetGithubContentError(); + } + + // Delete the file using its SHA + try { + await octokit.rest.repos.deleteFile({ + owner, + repo, + path, + message, + sha, + branch: branchRef, + }); + console.log(`File: ${path} deleted successfully.`); + } catch (error) { + throw new UpdateFileError(); + } +}; + /** * Creates an authenticated Octokit instance for a given GitHub App installation. * This function performs asynchronous operations to obtain the installation ID and