diff --git a/README.md b/README.md index a62a202..4b22924 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,9 @@ jobs: # Require all pull request statuses to be successful before # merging. Default is `false`. require_statuses_success: 'true' + # Label to apply to the pull request if the merge fails. Default is + # `automerge-fail`. + automerge_fail_label: 'merge-schedule-failed' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` diff --git a/action.yml b/action.yml index e940993..eb7a92d 100644 --- a/action.yml +++ b/action.yml @@ -24,3 +24,9 @@ inputs: merging. Default is `false`. required: false default: 'false' + automerge_fail_label: + description: |- + Label to apply to the pull request if the merge fails. Default is + `automerge-fail`. + required: false + default: 'automerge-fail' diff --git a/lib/comment.ts b/lib/comment.ts index 7e77f58..b8edd66 100644 --- a/lib/comment.ts +++ b/lib/comment.ts @@ -5,10 +5,14 @@ type Octokit = InstanceType; const commentHeader = "**Merge Schedule**"; const commentFooter = ""; +const commentFailFooter = ""; + +type CommentVariant = "default" | "fail"; export async function getPreviousComment( octokit: Octokit, - pullRequestNumber: number + pullRequestNumber: number, + variant: CommentVariant = "default" ) { const prComments = await octokit.paginate( octokit.rest.issues.listComments, @@ -18,7 +22,9 @@ export async function getPreviousComment( }, (response) => { return response.data.filter((comment) => - comment.body?.includes(commentFooter) + comment.body?.includes( + variant === "fail" ? commentFailFooter : commentFooter + ) ); } ); @@ -35,13 +41,18 @@ const statePrefix: Record = { pending: ":hourglass:", }; -export function generateBody(body: string, state: State) { +export function generateBody( + body: string, + state: State, + variant: CommentVariant = "default" +) { let newBody = body; if (!body.startsWith(commentHeader)) { newBody = `${commentHeader}\n${newBody}`; } - if (!body.endsWith(commentFooter)) { - newBody = `${newBody}\n${commentFooter}`; + const footer = variant === "fail" ? commentFailFooter : commentFooter; + if (!body.endsWith(footer)) { + newBody = `${newBody}\n${footer}`; } return `${statePrefix[state]} ${newBody}`; } diff --git a/lib/environment.d.ts b/lib/environment.d.ts index 9c08c55..ee936e8 100644 --- a/lib/environment.d.ts +++ b/lib/environment.d.ts @@ -7,6 +7,7 @@ declare global { INPUT_MERGE_METHOD: string; INPUT_TIME_ZONE: string; INPUT_REQUIRE_STATUSES_SUCCESS: string; + INPUT_AUTOMERGE_FAIL_LABEL: string; } } } diff --git a/lib/handle-pull-request.test.ts b/lib/handle-pull-request.test.ts index fde24ae..e0f01a8 100644 --- a/lib/handle-pull-request.test.ts +++ b/lib/handle-pull-request.test.ts @@ -98,7 +98,7 @@ describe("handlePullRequest", () => { ], [`Schedule date found: "bad-date"\n`], [ - `Comment created: https://github.com/gr2m/merge-schedule-action/issues/2#issuecomment-2\n`, + `Comment created: https://github.com/gr2m/merge-schedule-action/issues/2#issuecomment-22\n`, ], ]); expect(createComment.mock.calls).toHaveLength(1); @@ -125,7 +125,7 @@ describe("handlePullRequest", () => { ], [`Schedule date found: "2022-06-08"\n`], [ - `Comment created: https://github.com/gr2m/merge-schedule-action/issues/2#issuecomment-2\n`, + `Comment created: https://github.com/gr2m/merge-schedule-action/issues/2#issuecomment-22\n`, ], ]); expect(createComment.mock.calls).toHaveLength(1); @@ -153,7 +153,7 @@ describe("handlePullRequest", () => { ], [`Schedule date found: "2022-06-08"\n`], [ - `Comment created: https://github.com/gr2m/merge-schedule-action/issues/2#issuecomment-2\n`, + `Comment created: https://github.com/gr2m/merge-schedule-action/issues/2#issuecomment-22\n`, ], ]); expect(createComment.mock.calls).toHaveLength(1); @@ -180,7 +180,7 @@ describe("handlePullRequest", () => { ], [`Schedule date found: "2022-06-12"\n`], [ - `Comment created: https://github.com/gr2m/merge-schedule-action/issues/2#issuecomment-2\n`, + `Comment created: https://github.com/gr2m/merge-schedule-action/issues/2#issuecomment-22\n`, ], ]); expect(createComment.mock.calls).toHaveLength(1); @@ -208,7 +208,7 @@ describe("handlePullRequest", () => { ], [`Schedule date found: "2022-06-12"\n`], [ - `Comment updated: https://github.com/gr2m/merge-schedule-action/issues/2#issuecomment-1\n`, + `Comment updated: https://github.com/gr2m/merge-schedule-action/issues/3#issuecomment-31\n`, ], ]); expect(updateComment.mock.calls).toHaveLength(1); diff --git a/lib/handle-schedule.test.ts b/lib/handle-schedule.test.ts index 38d3dd8..9cfaebc 100644 --- a/lib/handle-schedule.test.ts +++ b/lib/handle-schedule.test.ts @@ -33,19 +33,24 @@ describe("handleSchedule", () => { expect(mockStdout.mock.calls).toEqual([ [`Loading open pull requests\n`], - [`4 scheduled pull requests found\n`], - [`3 due pull requests found\n`], + [`5 scheduled pull requests found\n`], + [`4 due pull requests found\n`], [`https://github.com/gr2m/merge-schedule-action/pull/2 merged\n`], [ - `Comment created: https://github.com/gr2m/merge-schedule-action/issues/2#issuecomment-2\n`, + `Comment created: https://github.com/gr2m/merge-schedule-action/issues/2#issuecomment-22\n`, ], [`https://github.com/gr2m/merge-schedule-action/pull/3 merged\n`], [ - `Comment updated: https://github.com/gr2m/merge-schedule-action/issues/2#issuecomment-1\n`, + `Comment updated: https://github.com/gr2m/merge-schedule-action/issues/3#issuecomment-31\n`, ], [ - `Comment created: https://github.com/gr2m/merge-schedule-action/issues/13#issuecomment-2\n`, + `Comment created: https://github.com/gr2m/merge-schedule-action/issues/13#issuecomment-132\n`, ], + [`Label added: "automerge-fail"\n`], + [ + `Comment updated: https://github.com/gr2m/merge-schedule-action/issues/6#issuecomment-61\n`, + ], + [`Label added: "automerge-fail"\n`], ]); expect(createComment.mock.calls).toHaveLength(2); expect(createComment.mock.calls[0][2]).toMatchInlineSnapshot(` @@ -56,14 +61,21 @@ describe("handleSchedule", () => { expect(createComment.mock.calls[1][2]).toMatchInlineSnapshot(` ":x: **Merge Schedule** Scheduled merge failed: Pull Request is not mergeable - " + In order to let the automerge-automation try again, the label \\"automerge-fail\\" should be removed. + " `); - expect(updateComment.mock.calls).toHaveLength(1); + expect(updateComment.mock.calls).toHaveLength(2); expect(updateComment.mock.calls[0][2]).toMatchInlineSnapshot(` ":white_check_mark: **Merge Schedule** Scheduled on 2022-06-09 (UTC) successfully merged " `); + expect(updateComment.mock.calls[1][2]).toMatchInlineSnapshot(` + ":x: **Merge Schedule** + Scheduled merge failed: Pull Request is not mergeable + In order to let the automerge-automation try again, the label \\"automerge-fail\\" should be removed. + " + `); }); test("due pull requests with require_statuses_success = true", async () => { @@ -76,18 +88,23 @@ describe("handleSchedule", () => { expect(mockStdout.mock.calls).toEqual([ [`Loading open pull requests\n`], - [`4 scheduled pull requests found\n`], - [`3 due pull requests found\n`], + [`5 scheduled pull requests found\n`], + [`4 due pull requests found\n`], [`https://github.com/gr2m/merge-schedule-action/pull/2 merged\n`], [ - `Comment created: https://github.com/gr2m/merge-schedule-action/issues/2#issuecomment-2\n`, + `Comment created: https://github.com/gr2m/merge-schedule-action/issues/2#issuecomment-22\n`, ], [ `https://github.com/gr2m/merge-schedule-action/pull/3 is not ready to be merged yet because all checks are not completed or statuses are not success\n`, ], [ - `Comment created: https://github.com/gr2m/merge-schedule-action/issues/13#issuecomment-2\n`, + `Comment created: https://github.com/gr2m/merge-schedule-action/issues/13#issuecomment-132\n`, ], + [`Label added: "automerge-fail"\n`], + [ + `Comment updated: https://github.com/gr2m/merge-schedule-action/issues/6#issuecomment-61\n`, + ], + [`Label added: "automerge-fail"\n`], ]); expect(createComment.mock.calls).toHaveLength(2); expect(createComment.mock.calls[0][2]).toMatchInlineSnapshot(` @@ -98,8 +115,15 @@ describe("handleSchedule", () => { expect(createComment.mock.calls[1][2]).toMatchInlineSnapshot(` ":x: **Merge Schedule** Scheduled merge failed: Pull Request is not mergeable - " + In order to let the automerge-automation try again, the label \\"automerge-fail\\" should be removed. + " + `); + expect(updateComment.mock.calls).toHaveLength(1); + expect(updateComment.mock.calls[0][2]).toMatchInlineSnapshot(` + ":x: **Merge Schedule** + Scheduled merge failed: Pull Request is not mergeable + In order to let the automerge-automation try again, the label \\"automerge-fail\\" should be removed. + " `); - expect(updateComment.mock.calls).toHaveLength(0); }); }); diff --git a/lib/handle-schedule.ts b/lib/handle-schedule.ts index cee2a48..eb1ef89 100644 --- a/lib/handle-schedule.ts +++ b/lib/handle-schedule.ts @@ -28,6 +28,7 @@ export default async function handleSchedule(): Promise { const mergeMethod = process.env.INPUT_MERGE_METHOD; const requireStatusesSuccess = process.env.INPUT_REQUIRE_STATUSES_SUCCESS === "true"; + const automergeFailLabel = process.env.INPUT_AUTOMERGE_FAIL_LABEL; if (!isValidMergeMethod(mergeMethod)) { core.setFailed(`merge_method "${mergeMethod}" is invalid`); return; @@ -46,6 +47,9 @@ export default async function handleSchedule(): Promise { return response.data .filter((pullRequest) => !isFork(pullRequest as SimplePullRequest)) .filter((pullRequest) => hasScheduleCommand(pullRequest.body)) + .filter((pullRequest) => + pullRequest.labels.every((label) => label.name !== automergeFailLabel) + ) .map((pullRequest) => { return { number: pullRequest.number, @@ -95,15 +99,39 @@ export default async function handleSchedule(): Promise { }); core.info(`${pullRequest.html_url} merged`); } catch (error) { - const { data } = await createComment( + const previousComment = await getPreviousComment( octokit, pullRequest.number, - generateBody( - `Scheduled merge failed: ${(error as Error).message}`, - "error" - ) + "fail" ); - core.info(`Comment created: ${data.html_url}`); + const commentBody = generateBody( + `Scheduled merge failed: ${ + (error as Error).message + }\nIn order to let the automerge-automation try again, the label "${automergeFailLabel}" should be removed.`, + "error", + "fail" + ); + if (previousComment) { + const { data } = await updateComment( + octokit, + previousComment.id, + commentBody + ); + core.info(`Comment updated: ${data.html_url}`); + } else { + const { data } = await createComment( + octokit, + pullRequest.number, + commentBody + ); + core.info(`Comment created: ${data.html_url}`); + } + await octokit.rest.issues.addLabels({ + ...github.context.repo, + issue_number: pullRequest.number, + labels: [automergeFailLabel], + }); + core.info(`Label added: "${automergeFailLabel}"`); continue; } diff --git a/test/mocks/github.ts b/test/mocks/github.ts index 82c1a3c..ca5bff6 100644 --- a/test/mocks/github.ts +++ b/test/mocks/github.ts @@ -1,7 +1,5 @@ import { rest } from "msw"; -const githubUrl = (path: string) => `https://api.github.com${path}`; - type BasePathParams = { owner: string; repo: string; @@ -23,27 +21,134 @@ type CommitStatusesPathParams = BasePathParams & { ref: string; }; +const githubUrl = (path: string) => `https://api.github.com${path}`; +const owner = "gr2m"; +const repo = "merge-schedule-action"; +const githubPullRequestUrl = (id: number) => + `https://github.com/${owner}/${repo}/pull/${id}`; +const pullRequests = [ + { + number: 2, + html_url: githubPullRequestUrl(2), + state: "open", + body: "Simple body\n/schedule 2022-06-08", + head: { + sha: "abc123success", + repo: { + fork: false, + }, + }, + labels: [], + }, + { + number: 3, + html_url: githubPullRequestUrl(3), + state: "open", + body: "Simple body\n/schedule 2022-06-09", + head: { + sha: "abc123pending", + repo: { + fork: false, + }, + }, + labels: [], + }, + { + number: 4, + html_url: githubPullRequestUrl(4), + state: "open", + body: "Simple body\n/schedule 2022-06-12", + head: { + sha: "abc123success", + repo: { + fork: false, + }, + }, + labels: [], + }, + { + number: 13, + html_url: githubPullRequestUrl(13), + state: "open", + body: "With conflicts body\n/schedule 2022-06-09", + head: { + sha: "abc123success", + repo: { + fork: false, + }, + }, + labels: [], + }, + { + number: 5, + html_url: githubPullRequestUrl(5), + state: "open", + body: "With automerge-fail label\n/schedule 2022-06-07", + head: { + sha: "abc123success", + repo: { + fork: false, + }, + }, + labels: [ + { + name: "automerge-fail", + }, + ], + }, + { + number: 6, + html_url: githubPullRequestUrl(6), + state: "open", + body: "With automerge-fail previous comment\n/schedule 2022-06-07", + head: { + sha: "abc123success", + repo: { + fork: false, + }, + }, + labels: [], + }, +]; +const pullRequestComments = pullRequests.map((pullRequest) => { + let body = ""; + switch (pullRequest.number) { + case 3: + body = ""; + break; + case 4: + body = `:hourglass: **Merge Schedule**\nScheduled to be merged on 2022-06-12 00:00:00 (UTC)\n`; + break; + case 6: + body = `:x: **Merge Schedule**\nScheduled merge failed: Pull Request is not mergeable\nIn order to let the automerge-automation try again, the label \\"automerge-fail\\" should be removed.\n`; + break; + default: + body = "Sample comment"; + break; + } + const id = parseInt(`${pullRequest.number}1`, 10); + return [ + { + id, + html_url: `https://github.com/${owner}/${repo}/issues/${pullRequest.number}#issuecomment-${id}`, + body, + }, + ]; +}); + export const githubHandlers = [ // List pull request comments // https://docs.github.com/en/rest/issues/comments#list-issue-comments rest.get<{}, IssueCommentsPathParams>( githubUrl("/repos/:owner/:repo/issues/:issue_number/comments"), (req, res, ctx) => { - const { owner, repo, issue_number } = req.params; + const { issue_number } = req.params; + const pullRequestIndex = pullRequests.findIndex( + (item) => item.number === +issue_number + ); return res( ctx.status(200), - ctx.json([ - { - id: 1, - html_url: `https://github.com/${owner}/${repo}/issues/${issue_number}#issuecomment-1`, - body: - issue_number === "4" - ? `:hourglass: **Merge Schedule**\nScheduled to be merged on 2022-06-12 00:00:00 (UTC)\n` - : issue_number === "3" - ? "" - : "Sample comment", - }, - ]) + ctx.json(pullRequestComments[pullRequestIndex]) ); } ), @@ -53,13 +158,14 @@ export const githubHandlers = [ rest.post<{ body: string }, IssueCommentsPathParams>( githubUrl("/repos/:owner/:repo/issues/:issue_number/comments"), (req, res, ctx) => { - const { owner, repo, issue_number } = req.params; + const { issue_number } = req.params; + const id = parseInt(`${issue_number}2`, 10); return res( ctx.status(201), ctx.json({ - id: 2, - html_url: `https://github.com/${owner}/${repo}/issues/${issue_number}#issuecomment-2`, - body: req.body.body || "", + id, + html_url: `https://github.com/${owner}/${repo}/issues/${issue_number}#issuecomment-${id}`, + body: req.body.body, }) ); } @@ -70,14 +176,15 @@ export const githubHandlers = [ rest.patch<{ body: string }, IssueCommentPathParams>( githubUrl("/repos/:owner/:repo/issues/comments/:comment_id"), (req, res, ctx) => { - const { owner, repo, comment_id } = req.params; + const { comment_id } = req.params; const id = parseInt(comment_id, 10); + const issueNumber = parseInt(comment_id.slice(0, -1), 10); return res( ctx.status(200), ctx.json({ id, - html_url: `https://github.com/${owner}/${repo}/issues/2#issuecomment-${id}`, - body: req.body.body || "", + html_url: `https://github.com/${owner}/${repo}/issues/${issueNumber}#issuecomment-${id}`, + body: req.body.body, }) ); } @@ -97,60 +204,7 @@ export const githubHandlers = [ rest.get<{}, BasePathParams>( githubUrl("/repos/:owner/:repo/pulls"), (req, res, ctx) => { - const { owner, repo } = req.params; - return res( - ctx.status(200), - ctx.json([ - { - number: 2, - html_url: `https://github.com/${owner}/${repo}/pull/2`, - state: "open", - body: "Simple body\n/schedule 2022-06-08", - head: { - sha: "abc123success", - repo: { - fork: false, - }, - }, - }, - { - number: 3, - html_url: `https://github.com/${owner}/${repo}/pull/3`, - state: "open", - body: "Simple body\n/schedule 2022-06-09", - head: { - sha: "abc123pending", - repo: { - fork: false, - }, - }, - }, - { - number: 4, - html_url: `https://github.com/${owner}/${repo}/pull/4`, - state: "open", - body: "Simple body\n/schedule 2022-06-12", - head: { - sha: "abc123success", - repo: { - fork: false, - }, - }, - }, - { - number: 13, - html_url: `https://github.com/${owner}/${repo}/pull/13`, - state: "open", - body: "With conflicts body\n/schedule 2022-06-09", - head: { - sha: "abc123success", - repo: { - fork: false, - }, - }, - }, - ]) - ); + return res(ctx.status(200), ctx.json(pullRequests)); } ), @@ -161,7 +215,7 @@ export const githubHandlers = [ (req, res, ctx) => { const pullNumber = parseInt(req.params.pull_number, 10); - if (pullNumber === 13) { + if ([13, 6].includes(pullNumber)) { return res( ctx.status(405), ctx.json({ @@ -215,4 +269,26 @@ export const githubHandlers = [ ); } ), + + // Add labels to an issue/pull request + // https://docs.github.com/en/rest/issues/labels#add-labels-to-an-issue + rest.post<{ labels: string[] }, BasePathParams>( + githubUrl("/repos/:owner/:repo/issues/:issue_number/labels"), + (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json( + req.body.labels.map((label, index) => ({ + id: index + 1, + node_id: `node-${index + 1}`, + url: `https://api.github.com/repos/${owner}/${repo}/labels/${label}`, + name: label, + description: null, + color: "000000", + default: false, + })) + ) + ); + } + ), ]; diff --git a/test/setup-test-env.ts b/test/setup-test-env.ts index 2b4e120..172922e 100644 --- a/test/setup-test-env.ts +++ b/test/setup-test-env.ts @@ -6,6 +6,7 @@ process.env.GITHUB_REPOSITORY = "gr2m/merge-schedule-action"; process.env.INPUT_MERGE_METHOD = "merge"; process.env.INPUT_TIME_ZONE = "UTC"; process.env.INPUT_REQUIRE_STATUSES_SUCCESS = "false"; +process.env.INPUT_AUTOMERGE_FAIL_LABEL = "automerge-fail"; beforeAll(() => server.listen({ onUnhandledRequest: "error" }));