diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6d2861..b792729 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: id: npm-ci-test run: npm run ci-test - test-action: + create-slack-message: name: GitHub Actions Test runs-on: ubuntu-latest @@ -53,12 +53,10 @@ jobs: id: checkout uses: actions/checkout@v4 - - name: Test Local Action - id: test-action + - name: Create message for Slack posts + id: create-message uses: ./ - with: - milliseconds: 2000 - name: Print Output id: output - run: echo "${{ steps.test-action.outputs.time }}" + run: echo "${{ steps.create-message.outputs.message }}" diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index ce373fb..5fa5f5f 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -7,13 +7,10 @@ */ import * as core from '@actions/core' -import * as main from '../src/main' +// import * as main from '../src/main' // Mock the action's main function -const runMock = jest.spyOn(main, 'run') - -// Other utilities -const timeRegex = /^\d{2}:\d{2}:\d{2}/ +// const runMock = jest.spyOn(main, 'run') // Mock the GitHub Actions core library let debugMock: jest.SpiedFunction @@ -22,7 +19,7 @@ let getInputMock: jest.SpiedFunction let setFailedMock: jest.SpiedFunction let setOutputMock: jest.SpiedFunction -describe('action', () => { +describe.skip('action', () => { beforeEach(() => { jest.clearAllMocks() @@ -32,58 +29,4 @@ describe('action', () => { setFailedMock = jest.spyOn(core, 'setFailed').mockImplementation() setOutputMock = jest.spyOn(core, 'setOutput').mockImplementation() }) - - it('sets the time output', async () => { - // Set the action's inputs as return values from core.getInput() - getInputMock.mockImplementation(name => { - switch (name) { - case 'milliseconds': - return '500' - default: - return '' - } - }) - - await main.run() - expect(runMock).toHaveReturned() - - // Verify that all of the core library functions were called correctly - expect(debugMock).toHaveBeenNthCalledWith(1, 'Waiting 500 milliseconds ...') - expect(debugMock).toHaveBeenNthCalledWith( - 2, - expect.stringMatching(timeRegex) - ) - expect(debugMock).toHaveBeenNthCalledWith( - 3, - expect.stringMatching(timeRegex) - ) - expect(setOutputMock).toHaveBeenNthCalledWith( - 1, - 'time', - expect.stringMatching(timeRegex) - ) - expect(errorMock).not.toHaveBeenCalled() - }) - - it('sets a failed status', async () => { - // Set the action's inputs as return values from core.getInput() - getInputMock.mockImplementation(name => { - switch (name) { - case 'milliseconds': - return 'this is not a number' - default: - return '' - } - }) - - await main.run() - expect(runMock).toHaveReturned() - - // Verify that all of the core library functions were called correctly - expect(setFailedMock).toHaveBeenNthCalledWith( - 1, - 'milliseconds not a number' - ) - expect(errorMock).not.toHaveBeenCalled() - }) }) diff --git a/__tests__/wait.test.ts b/__tests__/wait.test.ts deleted file mode 100644 index 1336aaa..0000000 --- a/__tests__/wait.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Unit tests for src/wait.ts - */ - -import { wait } from '../src/wait' -import { expect } from '@jest/globals' - -describe('wait.ts', () => { - it('throws an invalid number', async () => { - const input = parseInt('foo', 10) - expect(isNaN(input)).toBe(true) - - await expect(wait(input)).rejects.toThrow('milliseconds not a number') - }) - - it('waits with a valid number', async () => { - const start = new Date() - await wait(500) - const end = new Date() - - const delta = Math.abs(end.getTime() - start.getTime()) - - expect(delta).toBeGreaterThan(450) - }) -}) diff --git a/action.yml b/action.yml index 101186a..9f13ad0 100644 --- a/action.yml +++ b/action.yml @@ -1,11 +1,7 @@ -name: 'The name of your action here' -description: 'Provide a description here' -author: 'Your name or organization here' - -# Add your action's branding here. This will appear on the GitHub Marketplace. -branding: - icon: 'heart' - color: 'red' +name: 'GitHub notice to Slack' +description: + 'This workflow is triggered by the opening of a pull request and notifies + users assigned as reviewers via mention on Slack.' # Define your inputs here. inputs: diff --git a/package-lock.json b/package-lock.json index 27da3b7..4ba3d14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,12 @@ "version": "0.0.0", "license": "MIT", "dependencies": { - "@actions/core": "^1.10.1" + "@actions/core": "^1.10.1", + "@actions/github": "^6.0.0" }, "devDependencies": { "@jest/globals": "^29.7.0", + "@octokit/webhooks-types": "^7.5.1", "@types/jest": "^29.5.13", "@types/node": "^22.5.5", "@typescript-eslint/eslint-plugin": "^8.6.0", @@ -51,6 +53,17 @@ "uuid": "^8.3.2" } }, + "node_modules/@actions/github": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@actions/github/-/github-6.0.0.tgz", + "integrity": "sha512-alScpSVnYmjNEXboZjarjukQEzgCRmjMv6Xj47fsdnqGS73bjJNDpiiXmp8jr0UZLdUB6d9jW63IcmddUP+l0g==", + "dependencies": { + "@actions/http-client": "^2.2.0", + "@octokit/core": "^5.0.1", + "@octokit/plugin-paginate-rest": "^9.0.0", + "@octokit/plugin-rest-endpoint-methods": "^10.0.0" + } + }, "node_modules/@actions/http-client": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.0.tgz", @@ -1332,6 +1345,156 @@ "node": ">= 8" } }, + "node_modules/@octokit/auth-token": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", + "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.0.tgz", + "integrity": "sha512-1LFfa/qnMQvEOAdzlQymH0ulepxbxnCYAKJZfMci/5XJyIHWgEYnDmgnKakbTh7CH2tFQ5O60oYDvns4i9RAIg==", + "dependencies": { + "@octokit/auth-token": "^4.0.0", + "@octokit/graphql": "^7.1.0", + "@octokit/request": "^8.3.1", + "@octokit/request-error": "^5.1.0", + "@octokit/types": "^13.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/endpoint": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.5.tgz", + "integrity": "sha512-ekqR4/+PCLkEBF6qgj8WqJfvDq65RH85OAgrtnVp1mSxaXF03u2xW/hUdweGS5654IlC0wkNYC18Z50tSYTAFw==", + "dependencies": { + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.0.tgz", + "integrity": "sha512-r+oZUH7aMFui1ypZnAvZmn0KSqAUgE1/tUXIWaqUCa1758ts/Jio84GZuzsvUkme98kv0WFY8//n0J1Z+vsIsQ==", + "dependencies": { + "@octokit/request": "^8.3.0", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "22.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", + "integrity": "sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-9.2.1.tgz", + "integrity": "sha512-wfGhE/TAkXZRLjksFXuDZdmGnJQHvtU/joFQdweXUgzo1XwvBCD4o4+75NtFfjfLK5IwLf9vHTfSiU3sLRYpRw==", + "dependencies": { + "@octokit/types": "^12.6.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-20.0.0.tgz", + "integrity": "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==" + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", + "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", + "dependencies": { + "@octokit/openapi-types": "^20.0.0" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-10.4.1.tgz", + "integrity": "sha512-xV1b+ceKV9KytQe3zCVqjg+8GTGfDYwaT1ATU5isiUyVtlVAO3HNdzpS4sr4GBx4hxQ46s7ITtZrAsxG22+rVg==", + "dependencies": { + "@octokit/types": "^12.6.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/openapi-types": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-20.0.0.tgz", + "integrity": "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==" + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", + "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", + "dependencies": { + "@octokit/openapi-types": "^20.0.0" + } + }, + "node_modules/@octokit/request": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.4.0.tgz", + "integrity": "sha512-9Bb014e+m2TgBeEJGEbdplMVWwPmL1FPtggHQRkV+WVsMggPtEkLKPlcVYm/o8xKLkpJ7B+6N8WfQMtDLX2Dpw==", + "dependencies": { + "@octokit/endpoint": "^9.0.1", + "@octokit/request-error": "^5.1.0", + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.1.0.tgz", + "integrity": "sha512-GETXfE05J0+7H2STzekpKObFe765O5dlAKUTLNGeH+x47z7JjXHfsHKo5z21D/o/IOZTUEI6nyWyR+bZVP/n5Q==", + "dependencies": { + "@octokit/types": "^13.1.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/types": { + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.6.0.tgz", + "integrity": "sha512-CrooV/vKCXqwLa+osmHLIMUb87brpgUqlqkPGc6iE2wCkUvTrHiXFMhAKoDDaAAYJrtKtrFTgSQTg5nObBEaew==", + "dependencies": { + "@octokit/openapi-types": "^22.2.0" + } + }, + "node_modules/@octokit/webhooks-types": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-7.5.1.tgz", + "integrity": "sha512-1dozxWEP8lKGbtEu7HkRbK1F/nIPuJXNfT0gd96y6d3LcHZTtRtlf8xz3nicSJfesADxJyDh+mWBOsdLkqgzYw==", + "dev": true + }, "node_modules/@pkgr/core": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", @@ -1938,6 +2101,11 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==" + }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -2252,6 +2420,11 @@ "node": ">=0.10.0" } }, + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -4336,7 +4509,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "dependencies": { "wrappy": "1" } @@ -5375,6 +5547,11 @@ "integrity": "sha512-e/vggGopEfTKSvj4ihnOLTsqhrKRN3LeO6qSN/GxohhuRv8qH9bNQ4B8W7e/vFL+0XTnmHPB4/kegunZGA4Org==", "dev": true }, + "node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==" + }, "node_modules/update-browserslist-db": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", @@ -5504,8 +5681,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/write-file-atomic": { "version": "4.0.2", diff --git a/package.json b/package.json index 1d8854a..9c13120 100644 --- a/package.json +++ b/package.json @@ -66,10 +66,12 @@ ] }, "dependencies": { - "@actions/core": "^1.10.1" + "@actions/core": "^1.10.1", + "@actions/github": "^6.0.0" }, "devDependencies": { "@jest/globals": "^29.7.0", + "@octokit/webhooks-types": "^7.5.1", "@types/jest": "^29.5.13", "@types/node": "^22.5.5", "@typescript-eslint/eslint-plugin": "^8.6.0", diff --git a/src/create-context.ts b/src/create-context.ts new file mode 100644 index 0000000..7730f75 --- /dev/null +++ b/src/create-context.ts @@ -0,0 +1,47 @@ +import { PullRequestEvent } from '@octokit/webhooks-types' +import { toSlackUser } from './to-slack-user' + +interface ImageElement { + type: 'image' + image_url: string + alt_text: string +} + +interface TextElement { + type: 'mrkdwn' + text: string +} + +export interface Context { + type: 'context' + elements: (ImageElement | TextElement)[] +} + +// createContext is like a Footer, show PR creator and repository name +export function createContext(payload: PullRequestEvent): Context { + const { + pull_request: { user }, + repository: { full_name } + } = payload + + const creator = toSlackUser(user.login) + + const contextText = `${creator} - :github: ${full_name}` + + const context: Context = { + type: 'context', + elements: [ + { + type: 'image', + image_url: user.avatar_url, + alt_text: user.login + }, + { + type: 'mrkdwn', + text: contextText + } + ] + } + + return context +} diff --git a/src/create-section.ts b/src/create-section.ts new file mode 100644 index 0000000..0571389 --- /dev/null +++ b/src/create-section.ts @@ -0,0 +1,31 @@ +import { PullRequestEvent } from '@octokit/webhooks-types' +import { toSlackUsers } from './to-slack-users' + +export interface Section { + type: 'section' + text: { + type: 'mrkdwn' + text: string + } +} + +// createSectionでメッセージの本文を作成。レビュアーの一覧とPRタイトルのリンクが表示される。 +// toSlackUsersは Record をハードコードで管理していて、Githubのアカウント名からSlackのメンションを作成している +export function createSection(payload: PullRequestEvent): Section { + const { + pull_request: { title, html_url, number, requested_reviewers } + } = payload + + const reviewers = toSlackUsers(requested_reviewers) + + const sectionText = `${reviewers.join(' ')}\n*<${html_url}|#${number} - ${title}>*` + const section: Section = { + type: 'section', + text: { + type: 'mrkdwn', + text: sectionText + } + } + + return section +} diff --git a/src/create-slack-message.ts b/src/create-slack-message.ts new file mode 100644 index 0000000..c7798b6 --- /dev/null +++ b/src/create-slack-message.ts @@ -0,0 +1,28 @@ +import * as core from '@actions/core' +import * as github from '@actions/github' +import { PullRequestEvent } from '@octokit/webhooks-types' +import { createContext } from './create-context' +import { createSection } from './create-section' + +export function createSlackMessage(): void { + const payload = github.context.payload as PullRequestEvent + + if (payload.action !== 'opened') { + throw new Error( + 'This action is supposed to be run on pull_request opened event' + ) + } + + if (!payload.pull_request) { + throw new Error('This action is supposed to be run on pull_request event') + } + + const section = createSection(payload) + const context = createContext(payload) + + const message = JSON.stringify({ blocks: [section, context] }) + + console.log(message); + + core.setOutput('message', message) +} diff --git a/src/main.ts b/src/main.ts index c804f90..68a0331 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import * as core from '@actions/core' -import { wait } from './wait' +import { createSlackMessage } from './create-slack-message' /** * The main function for the action. @@ -7,18 +7,7 @@ import { wait } from './wait' */ export async function run(): Promise { try { - const ms: string = core.getInput('milliseconds') - - // Debug logs are only output if the `ACTIONS_STEP_DEBUG` secret is true - core.debug(`Waiting ${ms} milliseconds ...`) - - // Log the current timestamp, wait, then log the new timestamp - core.debug(new Date().toTimeString()) - await wait(parseInt(ms, 10)) - core.debug(new Date().toTimeString()) - - // Set outputs for other workflow steps to use - core.setOutput('time', new Date().toTimeString()) + createSlackMessage() } catch (error) { // Fail the workflow run if an error occurs if (error instanceof Error) core.setFailed(error.message) diff --git a/src/to-slack-user.ts b/src/to-slack-user.ts new file mode 100644 index 0000000..75609e7 --- /dev/null +++ b/src/to-slack-user.ts @@ -0,0 +1,21 @@ +/** + * Converts a GitHub username to a Slack username mention. + * If no mapping exists, returns the original GitHub username. + * + * @param githubUsername - The GitHub username to map. + * @returns The Slack mention (e.g., `<@Ryohei>`) or the original GitHub username. + */ +export function toSlackUser(githubUsername: string): string { + const userMappings: Record = { + rysiva: 'Ryohei', + resnoa: 'resident noah' + } + + const slackUsername = userMappings[githubUsername] + if (!slackUsername) { + console.warn(`No mapping found for GitHub username: ${githubUsername}`) + return githubUsername + } + + return `<@${slackUsername}>` +} diff --git a/src/to-slack-users.ts b/src/to-slack-users.ts new file mode 100644 index 0000000..86d957a --- /dev/null +++ b/src/to-slack-users.ts @@ -0,0 +1,11 @@ +import { User, Team } from '@octokit/webhooks-types' +import { toSlackUser } from './to-slack-user' + +export function toSlackUsers(requestedReviewers: (User | Team)[]): string[] { + return requestedReviewers.map(reviewer => { + if ('login' in reviewer) { + return toSlackUser(reviewer.login) + } + throw new Error('ログインプロパティが見つかりません') + }) +} diff --git a/src/wait.ts b/src/wait.ts deleted file mode 100644 index 0ddf692..0000000 --- a/src/wait.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Wait for a number of milliseconds. - * @param milliseconds The number of milliseconds to wait. - * @returns {Promise} Resolves with 'done!' after the wait is over. - */ -export async function wait(milliseconds: number): Promise { - return new Promise(resolve => { - if (isNaN(milliseconds)) { - throw new Error('milliseconds not a number') - } - - setTimeout(() => resolve('done!'), milliseconds) - }) -}