diff --git a/README.md b/README.md index b5707e4..581ad8c 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,19 @@ If for some reason, triggering on pr is not possible, you can use push. This message is from a push. ``` +### Override pull request number + +Use `number_force` to comment on a different pull request than the one that triggered the event. + +```yaml +- uses: marocchino/sticky-pull-request-comment@v2 + with: + number_force: 123 + message: | + This comment will be posted to PR #123, + regardless of which PR triggered this workflow. +``` + ### Read comment from a file ```yaml @@ -217,6 +230,10 @@ For more detailed information about permissions, you can read from the link belo **Optional** Pull request number for push event. Note that this has a **lower priority** than the number of a pull_request event. +### `number_force` + +**Optional** Pull request number for any event. Note that this has the **highest priority** and will override the number from a pull_request event. + ### `owner` **Optional** Another repository owner, If not set, the current repository owner is used by default. Note that when you trying changing a repo, be aware that `GITHUB_TOKEN` should also have permission for that repository. diff --git a/__tests__/config.test.ts b/__tests__/config.test.ts index 9ea14fa..b44a433 100644 --- a/__tests__/config.test.ts +++ b/__tests__/config.test.ts @@ -1,430 +1,213 @@ -import { beforeEach, afterEach, test, expect, vi, describe } from 'vitest' +import {afterEach, describe, expect, test, vi} from "vitest" +import {resolve} from "node:path" -const mockConfig = { - pullRequestNumber: 123, - repo: {owner: "marocchino", repo: "stick-pull-request-comment"}, - header: "", - append: false, - recreate: false, - deleteOldComment: false, - hideOldComment: false, - hideAndRecreate: false, - hideClassify: "OUTDATED", - hideDetails: false, - githubToken: "some-token", - ignoreEmpty: false, - skipUnchanged: false, - getBody: vi.fn().mockResolvedValue("") -} +vi.mock("@actions/core", () => ({ + getInput: vi.fn().mockReturnValue(""), + getBooleanInput: vi.fn().mockReturnValue(false), + getMultilineInput: vi.fn().mockReturnValue([]), + setFailed: vi.fn(), +})) -vi.mock('../src/config', () => { - return mockConfig -}) +const mockContext = vi.hoisted(() => ({ + repo: {owner: "marocchino", repo: "sticky-pull-request-comment"}, + payload: {} as Record, +})) -beforeEach(() => { - // Set up default environment variables for each test - process.env["GITHUB_REPOSITORY"] = "marocchino/stick-pull-request-comment" - process.env["INPUT_NUMBER"] = "123" - process.env["INPUT_APPEND"] = "false" - process.env["INPUT_RECREATE"] = "false" - process.env["INPUT_DELETE"] = "false" - process.env["INPUT_ONLY_CREATE"] = "false" - process.env["INPUT_ONLY_UPDATE"] = "false" - process.env["INPUT_HIDE"] = "false" - process.env["INPUT_HIDE_AND_RECREATE"] = "false" - process.env["INPUT_HIDE_CLASSIFY"] = "OUTDATED" - process.env["INPUT_HIDE_DETAILS"] = "false" - process.env["INPUT_GITHUB_TOKEN"] = "some-token" - process.env["INPUT_IGNORE_EMPTY"] = "false" - process.env["INPUT_SKIP_UNCHANGED"] = "false" - process.env["INPUT_FOLLOW_SYMBOLIC_LINKS"] = "false" - - // 모킹된 값 초기화 - mockConfig.pullRequestNumber = 123 - mockConfig.repo = {owner: "marocchino", repo: "stick-pull-request-comment"} - mockConfig.header = "" - mockConfig.append = false - mockConfig.recreate = false - mockConfig.deleteOldComment = false - mockConfig.hideOldComment = false - mockConfig.hideAndRecreate = false - mockConfig.hideClassify = "OUTDATED" - mockConfig.hideDetails = false - mockConfig.githubToken = "some-token" - mockConfig.ignoreEmpty = false - mockConfig.skipUnchanged = false - mockConfig.getBody.mockResolvedValue("") -}) +vi.mock("@actions/github", () => ({ + context: mockContext, +})) + +const mockGlobCreate = vi.hoisted(() => vi.fn()) + +vi.mock("@actions/glob", () => ({ + create: mockGlobCreate, +})) afterEach(() => { - vi.resetModules() - delete process.env["GITHUB_REPOSITORY"] - delete process.env["INPUT_OWNER"] - delete process.env["INPUT_REPO"] - delete process.env["INPUT_HEADER"] - delete process.env["INPUT_MESSAGE"] - delete process.env["INPUT_NUMBER"] - delete process.env["INPUT_APPEND"] - delete process.env["INPUT_RECREATE"] - delete process.env["INPUT_DELETE"] - delete process.env["INPUT_ONLY_CREATE"] - delete process.env["INPUT_ONLY_UPDATE"] - delete process.env["INPUT_HIDE"] - delete process.env["INPUT_HIDE_AND_RECREATE"] - delete process.env["INPUT_HIDE_CLASSIFY"] - delete process.env["INPUT_HIDE_DETAILS"] - delete process.env["INPUT_GITHUB_TOKEN"] - delete process.env["INPUT_PATH"] - delete process.env["INPUT_IGNORE_EMPTY"] - delete process.env["INPUT_SKIP_UNCHANGED"] - delete process.env["INPUT_FOLLOW_SYMBOLIC_LINKS"] + mockContext.payload = {} + mockContext.repo = {owner: "marocchino", repo: "sticky-pull-request-comment"} + mockGlobCreate.mockReset() }) -test("repo", async () => { - process.env["INPUT_OWNER"] = "jin" - process.env["INPUT_REPO"] = "other" - - mockConfig.repo = {owner: "jin", repo: "other"} - - const config = await import('../src/config') - expect(config).toMatchObject({ - pullRequestNumber: expect.any(Number), - repo: {owner: "jin", repo: "other"}, - header: "", - append: false, - recreate: false, - deleteOldComment: false, - hideOldComment: false, - hideAndRecreate: false, - hideClassify: "OUTDATED", - hideDetails: false, - githubToken: "some-token", - ignoreEmpty: false, - skipUnchanged: false +async function loadConfig( + setup?: (mocks: {core: typeof import("@actions/core")}) => void, +) { + vi.resetModules() + const core = await import("@actions/core") + // vi.resetModules clears the config module cache but not mock instances, + // so reset core back to default values before each test. + vi.mocked(core.getInput).mockReturnValue("") + vi.mocked(core.getBooleanInput).mockReturnValue(false) + vi.mocked(core.getMultilineInput).mockReturnValue([]) + setup?.({core}) + const config = await import("../src/config") + return {config, core} +} + +describe("pullRequestNumber", () => { + test("number_force takes highest priority", async () => { + mockContext.payload = {pull_request: {number: 789}} + const {config} = await loadConfig(({core}) => { + vi.mocked(core.getInput).mockImplementation(name => { + if (name === "number_force") return "456" + if (name === "number") return "123" + return "" + }) + }) + expect(config.pullRequestNumber).toBe(456) + }) + + test("falls back to context.payload.pull_request.number", async () => { + mockContext.payload = {pull_request: {number: 789}} + const {config} = await loadConfig() + expect(config.pullRequestNumber).toBe(789) + }) + + test("falls back to number input", async () => { + const {config} = await loadConfig(({core}) => { + vi.mocked(core.getInput).mockImplementation(name => (name === "number" ? "123" : "")) + }) + expect(config.pullRequestNumber).toBe(123) + }) +}) + +describe("repo", () => { + test("defaults to context.repo", async () => { + const {config} = await loadConfig() + expect(config.repo).toEqual({owner: "marocchino", repo: "sticky-pull-request-comment"}) + }) + + test("uses owner and repo inputs when provided", async () => { + const {config} = await loadConfig(({core}) => { + vi.mocked(core.getInput).mockImplementation(name => { + if (name === "owner") return "jin" + if (name === "repo") return "other" + return "" + }) + }) + expect(config.repo).toEqual({owner: "jin", repo: "other"}) }) - expect(await config.getBody()).toEqual("") }) test("header", async () => { - process.env["INPUT_HEADER"] = "header" - mockConfig.header = "header" - - const config = await import('../src/config') - expect(config).toMatchObject({ - pullRequestNumber: expect.any(Number), - repo: {owner: "marocchino", repo: "stick-pull-request-comment"}, - header: "header", - append: false, - recreate: false, - deleteOldComment: false, - hideOldComment: false, - hideAndRecreate: false, - hideClassify: "OUTDATED", - hideDetails: false, - githubToken: "some-token", - ignoreEmpty: false, - skipUnchanged: false + const {config} = await loadConfig(({core}) => { + vi.mocked(core.getInput).mockImplementation(name => (name === "header" ? "my-header" : "")) }) - expect(await config.getBody()).toEqual("") + expect(config.header).toBe("my-header") }) test("append", async () => { - process.env["INPUT_APPEND"] = "true" - mockConfig.append = true - - const config = await import('../src/config') - expect(config).toMatchObject({ - pullRequestNumber: expect.any(Number), - repo: {owner: "marocchino", repo: "stick-pull-request-comment"}, - header: "", - append: true, - recreate: false, - deleteOldComment: false, - hideOldComment: false, - hideAndRecreate: false, - hideClassify: "OUTDATED", - hideDetails: false, - githubToken: "some-token", - ignoreEmpty: false, - skipUnchanged: false + const {config} = await loadConfig(({core}) => { + vi.mocked(core.getBooleanInput).mockImplementation(name => name === "append") }) - expect(await config.getBody()).toEqual("") + expect(config.append).toBe(true) }) test("recreate", async () => { - process.env["INPUT_RECREATE"] = "true" - mockConfig.recreate = true - - const config = await import('../src/config') - expect(config).toMatchObject({ - pullRequestNumber: expect.any(Number), - repo: {owner: "marocchino", repo: "stick-pull-request-comment"}, - header: "", - append: false, - recreate: true, - deleteOldComment: false, - hideOldComment: false, - hideAndRecreate: false, - hideClassify: "OUTDATED", - hideDetails: false, - githubToken: "some-token", - ignoreEmpty: false, - skipUnchanged: false + const {config} = await loadConfig(({core}) => { + vi.mocked(core.getBooleanInput).mockImplementation(name => name === "recreate") }) - expect(await config.getBody()).toEqual("") + expect(config.recreate).toBe(true) }) -test("delete", async () => { - process.env["INPUT_DELETE"] = "true" - mockConfig.deleteOldComment = true - - const config = await import('../src/config') - expect(config).toMatchObject({ - pullRequestNumber: expect.any(Number), - repo: {owner: "marocchino", repo: "stick-pull-request-comment"}, - header: "", - append: false, - recreate: false, - deleteOldComment: true, - hideOldComment: false, - hideAndRecreate: false, - hideClassify: "OUTDATED", - hideDetails: false, - githubToken: "some-token", - ignoreEmpty: false, - skipUnchanged: false +test("deleteOldComment", async () => { + const {config} = await loadConfig(({core}) => { + vi.mocked(core.getBooleanInput).mockImplementation(name => name === "delete") }) - expect(await config.getBody()).toEqual("") + expect(config.deleteOldComment).toBe(true) }) test("hideOldComment", async () => { - process.env["INPUT_HIDE"] = "true" - mockConfig.hideOldComment = true - - const config = await import('../src/config') - expect(config).toMatchObject({ - pullRequestNumber: expect.any(Number), - repo: {owner: "marocchino", repo: "stick-pull-request-comment"}, - header: "", - append: false, - recreate: false, - deleteOldComment: false, - hideOldComment: true, - hideAndRecreate: false, - hideClassify: "OUTDATED", - hideDetails: false, - githubToken: "some-token", - ignoreEmpty: false, - skipUnchanged: false + const {config} = await loadConfig(({core}) => { + vi.mocked(core.getBooleanInput).mockImplementation(name => name === "hide") }) - expect(await config.getBody()).toEqual("") + expect(config.hideOldComment).toBe(true) }) test("hideAndRecreate", async () => { - process.env["INPUT_HIDE_AND_RECREATE"] = "true" - mockConfig.hideAndRecreate = true - - const config = await import('../src/config') - expect(config).toMatchObject({ - pullRequestNumber: expect.any(Number), - repo: {owner: "marocchino", repo: "stick-pull-request-comment"}, - header: "", - append: false, - recreate: false, - deleteOldComment: false, - hideOldComment: false, - hideAndRecreate: true, - hideClassify: "OUTDATED", - hideDetails: false, - githubToken: "some-token", - ignoreEmpty: false, - skipUnchanged: false + const {config} = await loadConfig(({core}) => { + vi.mocked(core.getBooleanInput).mockImplementation(name => name === "hide_and_recreate") }) - expect(await config.getBody()).toEqual("") + expect(config.hideAndRecreate).toBe(true) }) test("hideClassify", async () => { - process.env["INPUT_HIDE_CLASSIFY"] = "OFF_TOPIC" - mockConfig.hideClassify = "OFF_TOPIC" - - const config = await import('../src/config') - expect(config).toMatchObject({ - pullRequestNumber: expect.any(Number), - repo: {owner: "marocchino", repo: "stick-pull-request-comment"}, - header: "", - append: false, - recreate: false, - deleteOldComment: false, - hideOldComment: false, - hideAndRecreate: false, - hideClassify: "OFF_TOPIC", - hideDetails: false, - githubToken: "some-token", - ignoreEmpty: false, - skipUnchanged: false + const {config} = await loadConfig(({core}) => { + vi.mocked(core.getInput).mockImplementation(name => (name === "hide_classify" ? "OFF_TOPIC" : "")) }) - expect(await config.getBody()).toEqual("") + expect(config.hideClassify).toBe("OFF_TOPIC") }) test("hideDetails", async () => { - process.env["INPUT_HIDE_DETAILS"] = "true" - mockConfig.hideDetails = true - - const config = await import('../src/config') - expect(config).toMatchObject({ - pullRequestNumber: expect.any(Number), - repo: {owner: "marocchino", repo: "stick-pull-request-comment"}, - header: "", - append: false, - recreate: false, - deleteOldComment: false, - hideOldComment: false, - hideAndRecreate: false, - hideClassify: "OUTDATED", - hideDetails: true, - githubToken: "some-token", - ignoreEmpty: false, - skipUnchanged: false + const {config} = await loadConfig(({core}) => { + vi.mocked(core.getBooleanInput).mockImplementation(name => name === "hide_details") }) - expect(await config.getBody()).toEqual("") + expect(config.hideDetails).toBe(true) }) -describe("path", () => { - test("when exists return content of a file", async () => { - process.env["INPUT_PATH"] = "./__tests__/assets/result" - mockConfig.getBody.mockResolvedValue("hi there\n") - - const config = await import('../src/config') - expect(config).toMatchObject({ - pullRequestNumber: expect.any(Number), - repo: {owner: "marocchino", repo: "stick-pull-request-comment"}, - header: "", - append: false, - recreate: false, - deleteOldComment: false, - hideOldComment: false, - hideAndRecreate: false, - hideClassify: "OUTDATED", - hideDetails: false, - githubToken: "some-token", - ignoreEmpty: false, - skipUnchanged: false +test("ignoreEmpty", async () => { + const {config} = await loadConfig(({core}) => { + vi.mocked(core.getBooleanInput).mockImplementation(name => name === "ignore_empty") + }) + expect(config.ignoreEmpty).toBe(true) +}) + +test("skipUnchanged", async () => { + const {config} = await loadConfig(({core}) => { + vi.mocked(core.getBooleanInput).mockImplementation(name => name === "skip_unchanged") + }) + expect(config.skipUnchanged).toBe(true) +}) + +test("githubToken", async () => { + const {config} = await loadConfig(({core}) => { + vi.mocked(core.getInput).mockImplementation(name => (name === "GITHUB_TOKEN" ? "my-token" : "")) + }) + expect(config.githubToken).toBe("my-token") +}) + +describe("getBody", () => { + test("returns message when no path is provided", async () => { + const {config, core} = await loadConfig() + vi.mocked(core.getInput).mockImplementation(name => (name === "message" ? "hello there" : "")) + expect(await config.getBody()).toBe("hello there") + }) + + test("returns file content when path exists", async () => { + const {config, core} = await loadConfig() + vi.mocked(core.getMultilineInput).mockReturnValue(["__tests__/assets/result"]) + mockGlobCreate.mockResolvedValue({ + glob: vi.fn().mockResolvedValue([resolve("__tests__/assets/result")]), }) - expect(await config.getBody()).toEqual("hi there\n") + expect(await config.getBody()).toBe("hi there\n") }) - test("glob match files", async () => { - process.env["INPUT_PATH"] = "./__tests__/assets/*" - mockConfig.getBody.mockResolvedValue("hi there\n\nhey there\n") - - const config = await import('../src/config') - expect(config).toMatchObject({ - pullRequestNumber: expect.any(Number), - repo: {owner: "marocchino", repo: "stick-pull-request-comment"}, - header: "", - append: false, - recreate: false, - deleteOldComment: false, - hideOldComment: false, - hideAndRecreate: false, - hideClassify: "OUTDATED", - hideDetails: false, - githubToken: "some-token", - ignoreEmpty: false, - skipUnchanged: false + test("glob matches multiple files", async () => { + const {config, core} = await loadConfig() + vi.mocked(core.getMultilineInput).mockReturnValue(["__tests__/assets/*"]) + mockGlobCreate.mockResolvedValue({ + glob: vi + .fn() + .mockResolvedValue([ + resolve("__tests__/assets/result"), + resolve("__tests__/assets/result2"), + ]), }) - expect(await config.getBody()).toEqual("hi there\n\nhey there\n") + expect(await config.getBody()).toBe("hi there\n\nhey there\n") }) - test("when not exists return null string", async () => { - process.env["INPUT_PATH"] = "./__tests__/assets/not_exists" - mockConfig.getBody.mockResolvedValue("") - - const config = await import('../src/config') - expect(config).toMatchObject({ - pullRequestNumber: expect.any(Number), - repo: {owner: "marocchino", repo: "stick-pull-request-comment"}, - header: "", - append: false, - recreate: false, - deleteOldComment: false, - hideOldComment: false, - hideAndRecreate: false, - hideClassify: "OUTDATED", - hideDetails: false, - githubToken: "some-token", - ignoreEmpty: false, - skipUnchanged: false - }) - expect(await config.getBody()).toEqual("") + test("returns empty string when path matches no files", async () => { + const {config, core} = await loadConfig() + vi.mocked(core.getMultilineInput).mockReturnValue(["__tests__/assets/not_exists"]) + mockGlobCreate.mockResolvedValue({glob: vi.fn().mockResolvedValue([])}) + expect(await config.getBody()).toBe("") }) -}) -test("message", async () => { - process.env["INPUT_MESSAGE"] = "hello there" - mockConfig.getBody.mockResolvedValue("hello there") - - const config = await import('../src/config') - expect(config).toMatchObject({ - pullRequestNumber: expect.any(Number), - repo: {owner: "marocchino", repo: "stick-pull-request-comment"}, - header: "", - append: false, - recreate: false, - deleteOldComment: false, - hideOldComment: false, - hideAndRecreate: false, - hideClassify: "OUTDATED", - hideDetails: false, - githubToken: "some-token", - ignoreEmpty: false, - skipUnchanged: false + test("returns empty string and calls setFailed when glob throws", async () => { + const {config, core} = await loadConfig() + vi.mocked(core.getMultilineInput).mockReturnValue(["__tests__/assets/result"]) + mockGlobCreate.mockRejectedValue(new Error("glob error")) + expect(await config.getBody()).toBe("") + expect(core.setFailed).toHaveBeenCalledWith("glob error") }) - expect(await config.getBody()).toEqual("hello there") -}) - -test("ignore_empty", async () => { - process.env["INPUT_IGNORE_EMPTY"] = "true" - mockConfig.ignoreEmpty = true - - const config = await import('../src/config') - expect(config).toMatchObject({ - pullRequestNumber: expect.any(Number), - repo: {owner: "marocchino", repo: "stick-pull-request-comment"}, - header: "", - append: false, - recreate: false, - deleteOldComment: false, - hideOldComment: false, - hideAndRecreate: false, - hideClassify: "OUTDATED", - hideDetails: false, - githubToken: "some-token", - ignoreEmpty: true, - skipUnchanged: false - }) - expect(await config.getBody()).toEqual("") -}) - -test("skip_unchanged", async () => { - process.env["INPUT_SKIP_UNCHANGED"] = "true" - mockConfig.skipUnchanged = true - - const config = await import('../src/config') - expect(config).toMatchObject({ - pullRequestNumber: expect.any(Number), - repo: {owner: "marocchino", repo: "stick-pull-request-comment"}, - header: "", - append: false, - recreate: false, - deleteOldComment: false, - hideOldComment: false, - hideAndRecreate: false, - hideClassify: "OUTDATED", - hideDetails: false, - githubToken: "some-token", - ignoreEmpty: false, - skipUnchanged: true - }) - expect(await config.getBody()).toEqual("") }) diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index 8ddc0af..1fd3be7 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -115,8 +115,9 @@ describe("run", () => { mockConfig.recreate = true const {core} = await runMain() expect(core.setFailed).toHaveBeenCalledWith( - "delete and recreate cannot be both set to true", + "delete and recreate cannot be set to true simultaneously", ) + expect(mockConfig.getBody).not.toHaveBeenCalled() }) test("fails when deleteOldComment and onlyCreateComment are both true", async () => { @@ -124,8 +125,9 @@ describe("run", () => { mockConfig.onlyCreateComment = true const {core} = await runMain() expect(core.setFailed).toHaveBeenCalledWith( - "delete and only_create cannot be both set to true", + "delete and only_create cannot be set to true simultaneously", ) + expect(mockConfig.getBody).not.toHaveBeenCalled() }) test("fails when deleteOldComment and hideOldComment are both true", async () => { @@ -133,8 +135,9 @@ describe("run", () => { mockConfig.hideOldComment = true const {core} = await runMain() expect(core.setFailed).toHaveBeenCalledWith( - "delete and hide cannot be both set to true", + "delete and hide cannot be set to true simultaneously", ) + expect(mockConfig.getBody).not.toHaveBeenCalled() }) test("fails when onlyCreateComment and onlyUpdateComment are both true", async () => { @@ -142,8 +145,9 @@ describe("run", () => { mockConfig.onlyUpdateComment = true const {core} = await runMain() expect(core.setFailed).toHaveBeenCalledWith( - "only_create and only_update cannot be both set to true", + "only_create and only_update cannot be set to true simultaneously", ) + expect(mockConfig.getBody).not.toHaveBeenCalled() }) test("fails when hideOldComment and hideAndRecreate are both true", async () => { @@ -151,8 +155,19 @@ describe("run", () => { mockConfig.hideAndRecreate = true const {core} = await runMain() expect(core.setFailed).toHaveBeenCalledWith( - "hide and hide_and_recreate cannot be both set to true", + "hide and hide_and_recreate cannot be set to true simultaneously", ) + expect(mockConfig.getBody).not.toHaveBeenCalled() + }) + + test("fails when deleteOldComment and hideAndRecreate are both true", async () => { + mockConfig.deleteOldComment = true + mockConfig.hideAndRecreate = true + const {core} = await runMain() + expect(core.setFailed).toHaveBeenCalledWith( + "delete and hide_and_recreate cannot be set to true simultaneously", + ) + expect(mockConfig.getBody).not.toHaveBeenCalled() }) test("deletes previous comment when deleteOldComment is true and previous comment exists", async () => { diff --git a/__tests__/validate.test.ts b/__tests__/validate.test.ts new file mode 100644 index 0000000..0ece200 --- /dev/null +++ b/__tests__/validate.test.ts @@ -0,0 +1,85 @@ +import {describe, expect, test} from "vitest" +import {validateBody, validateExclusiveModes} from "../src/validate" + +describe("validateBody", () => { + test("throws when body is empty and neither delete nor hide is set", () => { + expect(() => validateBody("", false, false)).toThrow( + "Either message or path input is required", + ) + }) + + test("does not throw when body is provided", () => { + expect(() => validateBody("some body", false, false)).not.toThrow() + }) + + test("does not throw when body is empty but deleteOldComment is true", () => { + expect(() => validateBody("", true, false)).not.toThrow() + }) + + test("does not throw when body is empty but hideOldComment is true", () => { + expect(() => validateBody("", false, true)).not.toThrow() + }) +}) + +describe("validateExclusiveModes", () => { + test("does not throw when no modes are enabled", () => { + expect(() => validateExclusiveModes(false, false, false, false, false, false)).not.toThrow() + }) + + test("does not throw when exactly one mode is enabled", () => { + expect(() => validateExclusiveModes(true, false, false, false, false, false)).not.toThrow() + expect(() => validateExclusiveModes(false, true, false, false, false, false)).not.toThrow() + expect(() => validateExclusiveModes(false, false, true, false, false, false)).not.toThrow() + expect(() => validateExclusiveModes(false, false, false, true, false, false)).not.toThrow() + expect(() => validateExclusiveModes(false, false, false, false, true, false)).not.toThrow() + expect(() => validateExclusiveModes(false, false, false, false, false, true)).not.toThrow() + }) + + test("throws when delete and recreate are both true", () => { + expect(() => validateExclusiveModes(true, true, false, false, false, false)).toThrow( + "delete and recreate cannot be set to true simultaneously", + ) + }) + + test("throws when delete and only_create are both true", () => { + expect(() => validateExclusiveModes(true, false, true, false, false, false)).toThrow( + "delete and only_create cannot be set to true simultaneously", + ) + }) + + test("throws when delete and only_update are both true", () => { + expect(() => validateExclusiveModes(true, false, false, true, false, false)).toThrow( + "delete and only_update cannot be set to true simultaneously", + ) + }) + + test("throws when delete and hide are both true", () => { + expect(() => validateExclusiveModes(true, false, false, false, true, false)).toThrow( + "delete and hide cannot be set to true simultaneously", + ) + }) + + test("throws when delete and hide_and_recreate are both true", () => { + expect(() => validateExclusiveModes(true, false, false, false, false, true)).toThrow( + "delete and hide_and_recreate cannot be set to true simultaneously", + ) + }) + + test("throws when only_create and only_update are both true", () => { + expect(() => validateExclusiveModes(false, false, true, true, false, false)).toThrow( + "only_create and only_update cannot be set to true simultaneously", + ) + }) + + test("throws when hide and hide_and_recreate are both true", () => { + expect(() => validateExclusiveModes(false, false, false, false, true, true)).toThrow( + "hide and hide_and_recreate cannot be set to true simultaneously", + ) + }) + + test("uses Oxford comma when three or more modes are enabled", () => { + expect(() => validateExclusiveModes(true, true, true, false, false, false)).toThrow( + "delete, recreate, and only_create cannot be set to true simultaneously", + ) + }) +}) diff --git a/action.yml b/action.yml index bb0b430..d14ce06 100644 --- a/action.yml +++ b/action.yml @@ -72,6 +72,9 @@ inputs: number: description: "pull request number for push event" required: false + number_force: + description: "pull request number for any event" + required: false owner: description: "Another repo owner, If not set, the current repo owner is used by default. Note that when you trying changing a repo, be aware that GITHUB_TOKEN should also have permission for that repository." required: false diff --git a/biome.json b/biome.json index 04932cc..6d70561 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", "files": { "includes": ["src/**/*.ts"] }, diff --git a/package-lock.json b/package-lock.json index edf34e5..d198f40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@octokit/graphql-schema": "^15.26.1" }, "devDependencies": { - "@biomejs/biome": "2.4.6", + "@biomejs/biome": "2.4.7", "@rollup/plugin-commonjs": "^29.0.2", "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-typescript": "^12.3.0", @@ -98,9 +98,9 @@ "license": "MIT" }, "node_modules/@biomejs/biome": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.6.tgz", - "integrity": "sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ==", + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.7.tgz", + "integrity": "sha512-vXrgcmNGZ4lpdwZSpMf1hWw1aWS6B+SyeSYKTLrNsiUsAdSRN0J4d/7mF3ogJFbIwFFSOL3wT92Zzxia/d5/ng==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -114,20 +114,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.4.6", - "@biomejs/cli-darwin-x64": "2.4.6", - "@biomejs/cli-linux-arm64": "2.4.6", - "@biomejs/cli-linux-arm64-musl": "2.4.6", - "@biomejs/cli-linux-x64": "2.4.6", - "@biomejs/cli-linux-x64-musl": "2.4.6", - "@biomejs/cli-win32-arm64": "2.4.6", - "@biomejs/cli-win32-x64": "2.4.6" + "@biomejs/cli-darwin-arm64": "2.4.7", + "@biomejs/cli-darwin-x64": "2.4.7", + "@biomejs/cli-linux-arm64": "2.4.7", + "@biomejs/cli-linux-arm64-musl": "2.4.7", + "@biomejs/cli-linux-x64": "2.4.7", + "@biomejs/cli-linux-x64-musl": "2.4.7", + "@biomejs/cli-win32-arm64": "2.4.7", + "@biomejs/cli-win32-x64": "2.4.7" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.6.tgz", - "integrity": "sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ==", + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.7.tgz", + "integrity": "sha512-Oo0cF5mHzmvDmTXw8XSjhCia8K6YrZnk7aCS54+/HxyMdZMruMO3nfpDsrlar/EQWe41r1qrwKiCa2QDYHDzWA==", "cpu": [ "arm64" ], @@ -142,9 +142,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.6.tgz", - "integrity": "sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw==", + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.7.tgz", + "integrity": "sha512-I+cOG3sd/7HdFtvDSnF9QQPrWguUH7zrkIMMykM3PtfWU9soTcS2yRb9Myq6MHmzbeCT08D1UmY+BaiMl5CcoQ==", "cpu": [ "x64" ], @@ -159,9 +159,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.6.tgz", - "integrity": "sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew==", + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.7.tgz", + "integrity": "sha512-om6FugwmibzfP/6ALj5WRDVSND4H2G9X0nkI1HZpp2ySf9lW2j0X68oQSaHEnls6666oy4KDsc5RFjT4m0kV0w==", "cpu": [ "arm64" ], @@ -176,9 +176,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.6.tgz", - "integrity": "sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A==", + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.7.tgz", + "integrity": "sha512-I2NvM9KPb09jWml93O2/5WMfNR7Lee5Latag1JThDRMURVhPX74p9UDnyTw3Ae6cE1DgXfw7sqQgX7rkvpc0vw==", "cpu": [ "arm64" ], @@ -193,9 +193,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.6.tgz", - "integrity": "sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw==", + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.7.tgz", + "integrity": "sha512-bV8/uo2Tj+gumnk4sUdkerWyCPRabaZdv88IpbmDWARQQoA/Q0YaqPz1a+LSEDIL7OfrnPi9Hq1Llz4ZIGyIQQ==", "cpu": [ "x64" ], @@ -210,9 +210,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.6.tgz", - "integrity": "sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg==", + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.7.tgz", + "integrity": "sha512-00kx4YrBMU8374zd2wHuRV5wseh0rom5HqRND+vDldJPrWwQw+mzd/d8byI9hPx926CG+vWzq6AeiT7Yi5y59g==", "cpu": [ "x64" ], @@ -227,9 +227,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.6.tgz", - "integrity": "sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg==", + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.7.tgz", + "integrity": "sha512-hOUHBMlFCvDhu3WCq6vaBoG0dp0LkWxSEnEEsxxXvOa9TfT6ZBnbh72A/xBM7CBYB7WgwqboetzFEVDnMxelyw==", "cpu": [ "arm64" ], @@ -244,9 +244,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.6.tgz", - "integrity": "sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg==", + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.7.tgz", + "integrity": "sha512-qEpGjSkPC3qX4ycbMUthXvi9CkRq7kZpkqMY1OyhmYlYLnANnooDQ7hDerM8+0NJ+DZKVnsIc07h30XOpt7LtQ==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 37eb9fb..91bb598 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "@octokit/graphql-schema": "^15.26.1" }, "devDependencies": { - "@biomejs/biome": "2.4.6", + "@biomejs/biome": "2.4.7", "@rollup/plugin-commonjs": "^29.0.2", "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-typescript": "^12.3.0", diff --git a/src/config.ts b/src/config.ts index 615a7e6..bc71ddf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,7 +5,9 @@ import {create} from "@actions/glob" import type {ReportedContentClassifiers} from "@octokit/graphql-schema" export const pullRequestNumber = - context?.payload?.pull_request?.number || +core.getInput("number", {required: false}) + +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}) diff --git a/src/main.ts b/src/main.ts index 790bda7..65b165e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,6 +27,7 @@ import { repo, skipUnchanged, } from "./config" +import {validateBody, validateExclusiveModes} from "./validate" async function run(): Promise { if (Number.isNaN(pullRequestNumber) || pullRequestNumber < 1) { @@ -35,6 +36,15 @@ async function run(): Promise { } try { + validateExclusiveModes( + deleteOldComment, + recreate, + onlyCreateComment, + onlyUpdateComment, + hideOldComment, + hideAndRecreate, + ) + const body = await getBody() if (!body && ignoreEmpty) { @@ -42,29 +52,7 @@ async function run(): Promise { return } - if (!deleteOldComment && !hideOldComment && !body) { - throw new Error("Either message or path input is required") - } - - if (deleteOldComment && recreate) { - throw new Error("delete and recreate cannot be both set to true") - } - - if (deleteOldComment && onlyCreateComment) { - throw new Error("delete and only_create cannot be both set to true") - } - - if (deleteOldComment && hideOldComment) { - throw new Error("delete and hide cannot be both set to true") - } - - if (onlyCreateComment && onlyUpdateComment) { - throw new Error("only_create and only_update cannot be both set to true") - } - - if (hideOldComment && hideAndRecreate) { - throw new Error("hide and hide_and_recreate cannot be both set to true") - } + validateBody(body, deleteOldComment, hideOldComment) const octokit = github.getOctokit(githubToken) const previous = await findPreviousComment(octokit, repo, pullRequestNumber, header) diff --git a/src/validate.ts b/src/validate.ts new file mode 100644 index 0000000..9db2372 --- /dev/null +++ b/src/validate.ts @@ -0,0 +1,35 @@ +export function validateBody( + body: string, + deleteOldComment: boolean, + hideOldComment: boolean, +): void { + if (!deleteOldComment && !hideOldComment && !body) { + throw new Error("Either message or path input is required") + } +} + +export function validateExclusiveModes( + deleteOldComment: boolean, + recreate: boolean, + onlyCreateComment: boolean, + onlyUpdateComment: boolean, + hideOldComment: boolean, + hideAndRecreate: boolean, +): void { + const exclusiveModes: [string, boolean][] = [ + ["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`) + } +}