diff --git a/dist/comment.js b/dist/comment.js new file mode 100644 index 0000000..cc69957 --- /dev/null +++ b/dist/comment.js @@ -0,0 +1,117 @@ +import * as core from "@actions/core"; +function headerComment(header) { + return ``; +} +function bodyWithHeader(body, header) { + return `${body}\n${headerComment(header)}`; +} +function bodyWithoutHeader(body, header) { + return body.replace(`\n${headerComment(header)}`, ""); +} +export async function findPreviousComment(octokit, repo, number, header) { + let after = null; + let hasNextPage = true; + const h = headerComment(header); + while (hasNextPage) { + const data = await octokit.graphql(` + query($repo: String! $owner: String! $number: Int! $after: String) { + viewer { login } + repository(name: $repo owner: $owner) { + pullRequest(number: $number) { + comments(first: 100 after: $after) { + nodes { + id + author { + login + } + isMinimized + body + } + pageInfo { + endCursor + hasNextPage + } + } + } + } + } + `, { ...repo, after, number }); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const viewer = data.viewer; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const repository = data.repository; + const target = repository.pullRequest?.comments?.nodes?.find((node) => node?.author?.login === viewer.login.replace("[bot]", "") && + !node?.isMinimized && + node?.body?.includes(h)); + if (target) { + return target; + } + after = repository.pullRequest?.comments?.pageInfo?.endCursor; + hasNextPage = repository.pullRequest?.comments?.pageInfo?.hasNextPage ?? false; + } + return undefined; +} +export async function updateComment(octokit, id, body, header, previousBody) { + if (!body && !previousBody) + return core.warning("Comment body cannot be blank"); + const rawPreviousBody = previousBody ? bodyWithoutHeader(previousBody, header) : ""; + await octokit.graphql(` + mutation($input: UpdateIssueCommentInput!) { + updateIssueComment(input: $input) { + issueComment { + id + body + } + } + } + `, { + input: { + id, + body: previousBody + ? bodyWithHeader(`${rawPreviousBody}\n${body}`, header) + : bodyWithHeader(body, header), + }, + }); +} +export async function createComment(octokit, repo, issue_number, body, header, previousBody) { + if (!body && !previousBody) { + core.warning("Comment body cannot be blank"); + return; + } + return await octokit.rest.issues.createComment({ + ...repo, + issue_number, + body: previousBody ? `${previousBody}\n${body}` : bodyWithHeader(body, header), + }); +} +export async function deleteComment(octokit, id) { + await octokit.graphql(` + mutation($id: ID!) { + deleteIssueComment(input: { id: $id }) { + clientMutationId + } + } + `, { id }); +} +export async function minimizeComment(octokit, subjectId, classifier) { + await octokit.graphql(` + mutation($input: MinimizeCommentInput!) { + minimizeComment(input: $input) { + clientMutationId + } + } + `, { input: { subjectId, classifier } }); +} +export function getBodyOf(previous, append, hideDetails) { + if (!append) { + return undefined; + } + if (!hideDetails || !previous.body) { + return previous.body; + } + return previous.body.replace(/()/g, "$1$2"); +} +export function commentsEqual(body, previous, header) { + const newBody = bodyWithHeader(body, header); + return newBody === previous; +} diff --git a/dist/config.js b/dist/config.js new file mode 100644 index 0000000..42602d5 --- /dev/null +++ b/dist/config.js @@ -0,0 +1,65 @@ +import { readFileSync } from "node:fs"; +import * as core from "@actions/core"; +import { context } from "@actions/github"; +import { create } from "@actions/glob"; +export const pullRequestNumber = +core.getInput("number_force", { required: false }) || + context?.payload?.pull_request?.number || + +core.getInput("number", { required: false }); +export const repo = buildRepo(); +export const header = core.getInput("header", { required: false }); +export const append = core.getBooleanInput("append", { required: true }); +export const hideDetails = core.getBooleanInput("hide_details", { + required: true, +}); +export const recreate = core.getBooleanInput("recreate", { required: true }); +export const hideAndRecreate = core.getBooleanInput("hide_and_recreate", { + required: true, +}); +export const hideClassify = core.getInput("hide_classify", { + required: true, +}); +export const deleteOldComment = core.getBooleanInput("delete", { required: true }); +export const onlyCreateComment = core.getBooleanInput("only_create", { + required: true, +}); +export const onlyUpdateComment = core.getBooleanInput("only_update", { + required: true, +}); +export const skipUnchanged = core.getBooleanInput("skip_unchanged", { + required: true, +}); +export const hideOldComment = core.getBooleanInput("hide", { required: true }); +export const githubToken = core.getInput("GITHUB_TOKEN", { required: true }); +export const ignoreEmpty = core.getBooleanInput("ignore_empty", { + required: true, +}); +function buildRepo() { + return { + owner: core.getInput("owner", { required: false }) || context.repo.owner, + repo: core.getInput("repo", { required: false }) || context.repo.repo, + }; +} +export async function getBody() { + const pathInput = core.getMultilineInput("path", { required: false }); + const followSymbolicLinks = core.getBooleanInput("follow_symbolic_links", { + required: true, + }); + if (pathInput && pathInput.length > 0) { + try { + const globber = await create(pathInput.join("\n"), { + followSymbolicLinks, + matchDirectories: false, + }); + return (await globber.glob()).map(path => readFileSync(path, "utf-8")).join("\n"); + } + catch (error) { + if (error instanceof Error) { + core.setFailed(error.message); + } + return ""; + } + } + else { + return core.getInput("message", { required: false }); + } +} diff --git a/dist/main.js b/dist/main.js new file mode 100644 index 0000000..83eb6de --- /dev/null +++ b/dist/main.js @@ -0,0 +1,68 @@ +import * as core from "@actions/core"; +import * as github from "@actions/github"; +import { commentsEqual, createComment, deleteComment, findPreviousComment, getBodyOf, minimizeComment, updateComment, } from "./comment"; +import { append, deleteOldComment, getBody, githubToken, header, hideAndRecreate, hideClassify, hideDetails, hideOldComment, ignoreEmpty, onlyCreateComment, onlyUpdateComment, pullRequestNumber, recreate, repo, skipUnchanged, } from "./config"; +import { validateBody, validateExclusiveModes } from "./validate"; +async function run() { + if (Number.isNaN(pullRequestNumber) || pullRequestNumber < 1) { + core.info("no pull request numbers given: skip step"); + return; + } + try { + validateExclusiveModes(deleteOldComment, recreate, onlyCreateComment, onlyUpdateComment, hideOldComment, hideAndRecreate); + const body = await getBody(); + if (!body && ignoreEmpty) { + core.info("no body given: skip step by ignoreEmpty"); + return; + } + validateBody(body, deleteOldComment, hideOldComment); + const octokit = github.getOctokit(githubToken); + const previous = await findPreviousComment(octokit, repo, pullRequestNumber, header); + core.setOutput("previous_comment_id", previous?.id); + if (!previous) { + if (onlyUpdateComment || hideOldComment || deleteOldComment) { + return; + } + const created = await createComment(octokit, repo, pullRequestNumber, body, header); + core.setOutput("created_comment_id", created?.data.id); + return; + } + if (onlyCreateComment) { + // don't comment anything, user specified only_create and there is an + // existing comment, so this is probably a placeholder / introduction one. + return; + } + if (hideOldComment) { + await minimizeComment(octokit, previous.id, hideClassify); + return; + } + if (deleteOldComment) { + await deleteComment(octokit, previous.id); + return; + } + if (skipUnchanged && commentsEqual(body, previous.body || "", header)) { + // don't recreate or update if the message is unchanged + return; + } + const previousBody = getBodyOf({ body: previous.body || "" }, append, hideDetails); + if (recreate) { + await deleteComment(octokit, previous.id); + const created = await createComment(octokit, repo, pullRequestNumber, body, header, previousBody); + core.setOutput("created_comment_id", created?.data.id); + return; + } + if (hideAndRecreate) { + await minimizeComment(octokit, previous.id, hideClassify); + const created = await createComment(octokit, repo, pullRequestNumber, body, header); + core.setOutput("created_comment_id", created?.data.id); + return; + } + await updateComment(octokit, previous.id, body, header, previousBody); + } + catch (error) { + if (error instanceof Error) { + core.setFailed(error.message); + } + } +} +run(); diff --git a/dist/src/comment.js b/dist/src/comment.js new file mode 100644 index 0000000..cc69957 --- /dev/null +++ b/dist/src/comment.js @@ -0,0 +1,117 @@ +import * as core from "@actions/core"; +function headerComment(header) { + return ``; +} +function bodyWithHeader(body, header) { + return `${body}\n${headerComment(header)}`; +} +function bodyWithoutHeader(body, header) { + return body.replace(`\n${headerComment(header)}`, ""); +} +export async function findPreviousComment(octokit, repo, number, header) { + let after = null; + let hasNextPage = true; + const h = headerComment(header); + while (hasNextPage) { + const data = await octokit.graphql(` + query($repo: String! $owner: String! $number: Int! $after: String) { + viewer { login } + repository(name: $repo owner: $owner) { + pullRequest(number: $number) { + comments(first: 100 after: $after) { + nodes { + id + author { + login + } + isMinimized + body + } + pageInfo { + endCursor + hasNextPage + } + } + } + } + } + `, { ...repo, after, number }); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const viewer = data.viewer; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const repository = data.repository; + const target = repository.pullRequest?.comments?.nodes?.find((node) => node?.author?.login === viewer.login.replace("[bot]", "") && + !node?.isMinimized && + node?.body?.includes(h)); + if (target) { + return target; + } + after = repository.pullRequest?.comments?.pageInfo?.endCursor; + hasNextPage = repository.pullRequest?.comments?.pageInfo?.hasNextPage ?? false; + } + return undefined; +} +export async function updateComment(octokit, id, body, header, previousBody) { + if (!body && !previousBody) + return core.warning("Comment body cannot be blank"); + const rawPreviousBody = previousBody ? bodyWithoutHeader(previousBody, header) : ""; + await octokit.graphql(` + mutation($input: UpdateIssueCommentInput!) { + updateIssueComment(input: $input) { + issueComment { + id + body + } + } + } + `, { + input: { + id, + body: previousBody + ? bodyWithHeader(`${rawPreviousBody}\n${body}`, header) + : bodyWithHeader(body, header), + }, + }); +} +export async function createComment(octokit, repo, issue_number, body, header, previousBody) { + if (!body && !previousBody) { + core.warning("Comment body cannot be blank"); + return; + } + return await octokit.rest.issues.createComment({ + ...repo, + issue_number, + body: previousBody ? `${previousBody}\n${body}` : bodyWithHeader(body, header), + }); +} +export async function deleteComment(octokit, id) { + await octokit.graphql(` + mutation($id: ID!) { + deleteIssueComment(input: { id: $id }) { + clientMutationId + } + } + `, { id }); +} +export async function minimizeComment(octokit, subjectId, classifier) { + await octokit.graphql(` + mutation($input: MinimizeCommentInput!) { + minimizeComment(input: $input) { + clientMutationId + } + } + `, { input: { subjectId, classifier } }); +} +export function getBodyOf(previous, append, hideDetails) { + if (!append) { + return undefined; + } + if (!hideDetails || !previous.body) { + return previous.body; + } + return previous.body.replace(/()/g, "$1$2"); +} +export function commentsEqual(body, previous, header) { + const newBody = bodyWithHeader(body, header); + return newBody === previous; +} diff --git a/dist/src/config.js b/dist/src/config.js new file mode 100644 index 0000000..42602d5 --- /dev/null +++ b/dist/src/config.js @@ -0,0 +1,65 @@ +import { readFileSync } from "node:fs"; +import * as core from "@actions/core"; +import { context } from "@actions/github"; +import { create } from "@actions/glob"; +export const pullRequestNumber = +core.getInput("number_force", { required: false }) || + context?.payload?.pull_request?.number || + +core.getInput("number", { required: false }); +export const repo = buildRepo(); +export const header = core.getInput("header", { required: false }); +export const append = core.getBooleanInput("append", { required: true }); +export const hideDetails = core.getBooleanInput("hide_details", { + required: true, +}); +export const recreate = core.getBooleanInput("recreate", { required: true }); +export const hideAndRecreate = core.getBooleanInput("hide_and_recreate", { + required: true, +}); +export const hideClassify = core.getInput("hide_classify", { + required: true, +}); +export const deleteOldComment = core.getBooleanInput("delete", { required: true }); +export const onlyCreateComment = core.getBooleanInput("only_create", { + required: true, +}); +export const onlyUpdateComment = core.getBooleanInput("only_update", { + required: true, +}); +export const skipUnchanged = core.getBooleanInput("skip_unchanged", { + required: true, +}); +export const hideOldComment = core.getBooleanInput("hide", { required: true }); +export const githubToken = core.getInput("GITHUB_TOKEN", { required: true }); +export const ignoreEmpty = core.getBooleanInput("ignore_empty", { + required: true, +}); +function buildRepo() { + return { + owner: core.getInput("owner", { required: false }) || context.repo.owner, + repo: core.getInput("repo", { required: false }) || context.repo.repo, + }; +} +export async function getBody() { + const pathInput = core.getMultilineInput("path", { required: false }); + const followSymbolicLinks = core.getBooleanInput("follow_symbolic_links", { + required: true, + }); + if (pathInput && pathInput.length > 0) { + try { + const globber = await create(pathInput.join("\n"), { + followSymbolicLinks, + matchDirectories: false, + }); + return (await globber.glob()).map(path => readFileSync(path, "utf-8")).join("\n"); + } + catch (error) { + if (error instanceof Error) { + core.setFailed(error.message); + } + return ""; + } + } + else { + return core.getInput("message", { required: false }); + } +} diff --git a/dist/src/main.js b/dist/src/main.js new file mode 100644 index 0000000..83eb6de --- /dev/null +++ b/dist/src/main.js @@ -0,0 +1,68 @@ +import * as core from "@actions/core"; +import * as github from "@actions/github"; +import { commentsEqual, createComment, deleteComment, findPreviousComment, getBodyOf, minimizeComment, updateComment, } from "./comment"; +import { append, deleteOldComment, getBody, githubToken, header, hideAndRecreate, hideClassify, hideDetails, hideOldComment, ignoreEmpty, onlyCreateComment, onlyUpdateComment, pullRequestNumber, recreate, repo, skipUnchanged, } from "./config"; +import { validateBody, validateExclusiveModes } from "./validate"; +async function run() { + if (Number.isNaN(pullRequestNumber) || pullRequestNumber < 1) { + core.info("no pull request numbers given: skip step"); + return; + } + try { + validateExclusiveModes(deleteOldComment, recreate, onlyCreateComment, onlyUpdateComment, hideOldComment, hideAndRecreate); + const body = await getBody(); + if (!body && ignoreEmpty) { + core.info("no body given: skip step by ignoreEmpty"); + return; + } + validateBody(body, deleteOldComment, hideOldComment); + const octokit = github.getOctokit(githubToken); + const previous = await findPreviousComment(octokit, repo, pullRequestNumber, header); + core.setOutput("previous_comment_id", previous?.id); + if (!previous) { + if (onlyUpdateComment || hideOldComment || deleteOldComment) { + return; + } + const created = await createComment(octokit, repo, pullRequestNumber, body, header); + core.setOutput("created_comment_id", created?.data.id); + return; + } + if (onlyCreateComment) { + // don't comment anything, user specified only_create and there is an + // existing comment, so this is probably a placeholder / introduction one. + return; + } + if (hideOldComment) { + await minimizeComment(octokit, previous.id, hideClassify); + return; + } + if (deleteOldComment) { + await deleteComment(octokit, previous.id); + return; + } + if (skipUnchanged && commentsEqual(body, previous.body || "", header)) { + // don't recreate or update if the message is unchanged + return; + } + const previousBody = getBodyOf({ body: previous.body || "" }, append, hideDetails); + if (recreate) { + await deleteComment(octokit, previous.id); + const created = await createComment(octokit, repo, pullRequestNumber, body, header, previousBody); + core.setOutput("created_comment_id", created?.data.id); + return; + } + if (hideAndRecreate) { + await minimizeComment(octokit, previous.id, hideClassify); + const created = await createComment(octokit, repo, pullRequestNumber, body, header); + core.setOutput("created_comment_id", created?.data.id); + return; + } + await updateComment(octokit, previous.id, body, header, previousBody); + } + catch (error) { + if (error instanceof Error) { + core.setFailed(error.message); + } + } +} +run(); diff --git a/dist/src/validate.js b/dist/src/validate.js new file mode 100644 index 0000000..9af4c75 --- /dev/null +++ b/dist/src/validate.js @@ -0,0 +1,22 @@ +export function validateBody(body, deleteOldComment, hideOldComment) { + if (!deleteOldComment && !hideOldComment && !body) { + throw new Error("Either message or path input is required"); + } +} +export function validateExclusiveModes(deleteOldComment, recreate, onlyCreateComment, onlyUpdateComment, hideOldComment, hideAndRecreate) { + const exclusiveModes = [ + ["delete", deleteOldComment], + ["recreate", recreate], + ["only_create", onlyCreateComment], + ["only_update", onlyUpdateComment], + ["hide", hideOldComment], + ["hide_and_recreate", hideAndRecreate], + ]; + const enabledModes = exclusiveModes.filter(([, flag]) => flag).map(([name]) => name); + if (enabledModes.length > 1) { + const last = enabledModes[enabledModes.length - 1]; + const rest = enabledModes.slice(0, -1); + const joined = enabledModes.length === 2 ? `${rest[0]} and ${last}` : `${rest.join(", ")}, and ${last}`; + throw new Error(`${joined} cannot be set to true simultaneously`); + } +} diff --git a/dist/validate.js b/dist/validate.js new file mode 100644 index 0000000..9af4c75 --- /dev/null +++ b/dist/validate.js @@ -0,0 +1,22 @@ +export function validateBody(body, deleteOldComment, hideOldComment) { + if (!deleteOldComment && !hideOldComment && !body) { + throw new Error("Either message or path input is required"); + } +} +export function validateExclusiveModes(deleteOldComment, recreate, onlyCreateComment, onlyUpdateComment, hideOldComment, hideAndRecreate) { + const exclusiveModes = [ + ["delete", deleteOldComment], + ["recreate", recreate], + ["only_create", onlyCreateComment], + ["only_update", onlyUpdateComment], + ["hide", hideOldComment], + ["hide_and_recreate", hideAndRecreate], + ]; + const enabledModes = exclusiveModes.filter(([, flag]) => flag).map(([name]) => name); + if (enabledModes.length > 1) { + const last = enabledModes[enabledModes.length - 1]; + const rest = enabledModes.slice(0, -1); + const joined = enabledModes.length === 2 ? `${rest[0]} and ${last}` : `${rest.join(", ")}, and ${last}`; + throw new Error(`${joined} cannot be set to true simultaneously`); + } +} diff --git a/tsconfig.json b/tsconfig.json index ce3a3e7..41ed08a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,8 @@ "noUnusedLocals": true, "noUnusedParameters": false, "outDir": "./dist", + "rootDir": "./src", + "types": ["node"], "pretty": true, "resolveJsonModule": true, "strict": true,