diff --git a/README.md b/README.md index ff8094c..d2fa1c7 100644 --- a/README.md +++ b/README.md @@ -5,49 +5,50 @@ This GitHub Action sends notifications to a Slack channel whenever a pull reques ## Features 1. **Initial Commit List Notification**: When a pull request is created (opened), the action sends a message to a designated Slack channel with the PR details: - - **Message**: - ``` - New release pull request created: "PR title" - Branch: source_branch -> target_branch - ``` - - Additionally, a thread is created with a list of commits related to the PR: - ``` - Commits in this pull request: - - commit1 by @SlackUser1 - - commit2 by @commit.author - ``` - - If `github-to-slack-map` is provided, the action will use the Slack user instead of GitHub usernames to tag users in Slack messages. + + - **Message**: + ``` + New release pull request created: "PR title" + Branch: source_branch -> target_branch + ``` + - Additionally, a thread is created with a list of commits related to the PR: + ``` + Commits in this pull request: + - commit1 by @SlackUser1 + - commit2 by @commit.author + ``` + - If `github-to-slack-map` is provided, the action will use the Slack user instead of GitHub usernames to tag users in Slack messages. 2. **Slack Message Timestamp in PR Description**: The action updates the PR description to include the Slack message timestamp (`Slack message_ts`). This allows the timestamp to be retrieved later for sending follow-up messages in the same Slack thread when new commits are pushed to the branch. 3. **New Commits Notification**: When a pull request is updated (synchronized), the action retrieves the Slack message timestamp from the PR body (description) and sends a message with the last commit added to the PR using the GitHub username of the commit author: - - **Message**: - ``` - New commit added: last commit by @githubUser - ``` + + - **Message**: + ``` + New commit added: last commit by @githubUser + ``` 4. **PR Merged Notification**: When a pull request is merged (closed), the action fetches the Slack message timestamp and sends a notification to the same Slack thread, informing the team that the PR has been merged using the GitHub username of the user who merged the PR: - - **Message**: - ``` - Pull request "PR title" was merged by @githubUser - ``` + - **Message**: + ``` + Pull request "PR title" was merged by @githubUser + ``` ## Inputs -| Input | Required | Description | Default Value | -|------------------------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------| -| slack-bot-token | required | The Slack bot token. | N/A | -| slack-channel | required | The Slack channel ID where the notifications will be sent. | N/A | -| github-token | required | The GitHub token (typically `${{ secrets.GITHUB_TOKEN }}`). | N/A | -| github-to-slack-map | optional | A JSON string mapping GitHub usernames to Slack user IDs for tagging in messages. If not provided, GitHub usernames will be used in the Slack messages. Example: `{"githubUsername1": "slackUserID1", "githubUsername2": "slackUserID2"}`. | N/A | -| initial-message-template | optional | Template for the initial message when a PR is created. | `New release pull request created: \<${prUrl}\|\${prTitle}>\n*From*: ${branchName} → *To*: ${targetBranch}` | -| commit-list-message-template | optional | Template for the message with the list of commits when a PR is created. | `Commits in this pull request:\n${commitListMessage}\n\n\<${changelogUrl}\|Full Changelog: ${branchName} to ${targetBranch}>` | -| update-message-template | optional | Template for the message when a PR is updated with new commits. | `New commit added: \<${commitUrl}\|\${commitMessage}> by @${githubUser}` | -| close-message-template | optional | Template for the message when a PR is merged. | `Pull request \<${prUrl}\|\${prTitle}> was merged by @${mergedBy}` | - - +| Input | Required | Description | Default Value | +| ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | +| slack-bot-token | required | The Slack bot token. | N/A | +| slack-channel | required | The Slack channel ID where the notifications will be sent. | N/A | +| github-token | required | The GitHub token (typically `${{ secrets.GITHUB_TOKEN }}`). | N/A | +| github-to-slack-map | optional | A JSON string mapping GitHub usernames to Slack user IDs for tagging in messages. If not provided, GitHub usernames will be used in the Slack messages. Example: `{"githubUsername1": "slackUserID1", "githubUsername2": "slackUserID2"}`. | N/A | +| initial-message-template | optional | Template for the initial message when a PR is created. | `New release pull request created: \<${prUrl}\|\${prTitle}>\n*From*: ${branchName} → *To*: ${targetBranch}` | +| commit-list-message-template | optional | Template for the message with the list of commits when a PR is created. | `Commits in this pull request:\n${commitListMessage}\n\n\<${changelogUrl}\|Full Changelog: ${branchName} to ${targetBranch}>` | +| update-message-template | optional | Template for the message when a PR is updated with new commits. | `New commit added: \<${commitUrl}\|\${commitMessage}> by @${githubUser}` | +| close-message-template | optional | Template for the message when a PR is merged. | `Pull request \<${prUrl}\|\${prTitle}> was merged by @${mergedBy}` | To use this action, create a workflow file in your repository (e.g., `.github/workflows/release-notifications.yml`) with the following content: + ```yml name: Release Notification on PR Opened or Updated @@ -91,8 +92,8 @@ jobs: 1. In your app settings, go to "OAuth & Permissions". 2. Scroll down to "Bot Token Scopes" and add the following scopes: - - `chat:write` - - `chat:write.public` + - `chat:write` + - `chat:write.public` 3. Click "Save Changes". #### Install App to Workspace: @@ -111,7 +112,35 @@ jobs: Ensure that the GitHub token (`GITHUB_TOKEN`) has read and write permissions. You can configure this in your repository settings under `Settings` > `Actions` > `General` > `Workflow permissions`. - ## License This project is licensed under the MIT License - see the LICENSE file for details. +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 diff --git a/dist/handlePROpened.js b/dist/handlePROpened.js index 682e313..2842bd0 100644 --- a/dist/handlePROpened.js +++ b/dist/handlePROpened.js @@ -25,6 +25,42 @@ var __importStar = (this && this.__importStar) || function (mod) { Object.defineProperty(exports, "__esModule", { value: true }); exports.handlePROpened = void 0; const github = __importStar(require("@actions/github")); +const core = __importStar(require("@actions/core")); +async function fetchAllCommits(owner, repo, pullNumber, githubToken) { + const allCommits = []; + let url = `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/commits?per_page=100`; + let page = 1; + while (url) { + core.info(`Fetching page ${page}: ${url}`); + const response = await fetch(url, { + headers: { + Authorization: `token ${githubToken}`, + }, + }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(`GitHub API request failed: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`); + } + const commitsData = await response.json(); + core.info(`Fetched ${commitsData.length} commits on page ${page}`); + if (!Array.isArray(commitsData) || commitsData.length === 0) { + break; + } + allCommits.push(...commitsData); + const linkHeader = response.headers.get('link'); + core.info(`Link Header: ${linkHeader}`); + if (linkHeader) { + const nextLinkMatch = linkHeader.match(/<([^>]+)>;\s*rel="next"/); + url = nextLinkMatch ? nextLinkMatch[1] : null; + } + else { + url = null; + } + page++; + } + core.info(`Fetched a total of ${allCommits.length} commits`); + return allCommits; +} async function handlePROpened(slackToken, slackChannel, githubToken, initialMessageTemplate, commitListMessageTemplate, githubToSlackMap) { const pr = github.context.payload.pull_request; if (!pr) { @@ -65,15 +101,11 @@ async function handlePROpened(slackToken, slackChannel, githubToken, initialMess pull_number: prNumber, body: newPrBody, }); - const commitsUrl = pr.commits_url; - const commitsResponse = await fetch(commitsUrl, { - headers: { - Authorization: `token ${githubToken}`, - }, - }); - const commitsData = await commitsResponse.json(); - const repoUrl = `https://github.com/${github.context.repo.owner}/${github.context.repo.repo}`; - const commitMessages = commitsData + const { owner, repo } = github.context.repo; + core.info(`Commits URL: https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/commits`); + const commitsData = await fetchAllCommits(owner, repo, prNumber, githubToken); + const repoUrl = `https://github.com/${owner}/${repo}`; + let commitMessages = commitsData .map((commit) => { const commitMessage = commit.commit.message.split('\n')[0]; // Extract only the first line const commitSha = commit.sha; @@ -86,24 +118,47 @@ async function handlePROpened(slackToken, slackChannel, githubToken, initialMess return `- <${commitUrl}|${commitMessage}> by ${userDisplay}`; }) .join('\n'); - const changelogUrl = `${repoUrl}/compare/${targetBranch}...${branchName}`; - const commitListMessage = commitListMessageTemplate - .replace('${commitListMessage}', commitMessages) - .replace('${changelogUrl}', changelogUrl) - .replace('${branchName}', branchName) - .replace('${targetBranch}', targetBranch) - .replace(/\\n/g, '\n'); // Replace escaped newline characters with actual newline characters - await fetch('https://slack.com/api/chat.postMessage', { - method: 'POST', - headers: { - Authorization: `Bearer ${slackToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - channel: slackChannel, - text: commitListMessage, - thread_ts: messageTs, - }), - }); + if (commitMessages.length > 4000) { + // Slack message limit is 4000 characters + const commitMessagesArr = commitMessages.match(/[\s\S]{1,4000}/g) || []; + for (let i = 0; i < commitMessagesArr.length; i++) { + const text = i === commitMessagesArr.length - 1 + ? `${commitMessagesArr[i]}\n\n<${repoUrl}/compare/${targetBranch}...${branchName}|Full Changelog: ${branchName} to ${targetBranch}>` + : commitMessagesArr[i]; + await fetch('https://slack.com/api/chat.postMessage', { + method: 'POST', + headers: { + Authorization: `Bearer ${slackToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + channel: slackChannel, + text: text, + thread_ts: messageTs, + }), + }); + } + } + else { + const changelogUrl = `${repoUrl}/compare/${targetBranch}...${branchName}`; + const commitListMessage = commitListMessageTemplate + .replace('${commitListMessage}', commitMessages) + .replace('${changelogUrl}', changelogUrl) + .replace('${branchName}', branchName) + .replace('${targetBranch}', targetBranch) + .replace(/\\n/g, '\n'); // Replace escaped newline characters with actual newline characters + await fetch('https://slack.com/api/chat.postMessage', { + method: 'POST', + headers: { + Authorization: `Bearer ${slackToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + channel: slackChannel, + text: commitListMessage, + thread_ts: messageTs, + }), + }); + } } exports.handlePROpened = handlePROpened; diff --git a/src/handlePROpened.ts b/src/handlePROpened.ts index 10b82a4..d509e90 100644 --- a/src/handlePROpened.ts +++ b/src/handlePROpened.ts @@ -1,4 +1,5 @@ import * as github from '@actions/github'; +import * as core from '@actions/core'; interface Commit { sha: string; @@ -13,6 +14,60 @@ interface Commit { } | null; } +async function fetchAllCommits( + owner: string, + repo: string, + pullNumber: number, + githubToken: string +): Promise { + const allCommits: Commit[] = []; + let url: + | string + | null = `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/commits?per_page=100`; + let page = 1; + + while (url) { + core.info(`Fetching page ${page}: ${url}`); + const response: Response = await fetch(url, { + headers: { + Authorization: `token ${githubToken}`, + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + `GitHub API request failed: ${response.status} ${ + response.statusText + } - ${JSON.stringify(errorData)}` + ); + } + + const commitsData: Commit[] = await response.json(); + core.info(`Fetched ${commitsData.length} commits on page ${page}`); + + if (!Array.isArray(commitsData) || commitsData.length === 0) { + break; + } + + allCommits.push(...commitsData); + + const linkHeader: string | null = response.headers.get('link'); + core.info(`Link Header: ${linkHeader}`); + if (linkHeader) { + const nextLinkMatch = linkHeader.match(/<([^>]+)>;\s*rel="next"/); + url = nextLinkMatch ? nextLinkMatch[1] : null; + } else { + url = null; + } + + page++; + } + + core.info(`Fetched a total of ${allCommits.length} commits`); + return allCommits; +} + export async function handlePROpened( slackToken: string, slackChannel: string, @@ -40,7 +95,7 @@ export async function handlePROpened( .replace('${targetBranch}', targetBranch) .replace(/\\n/g, '\n'); - const initialMessageResponse = await fetch( + const initialMessageResponse: Response = await fetch( 'https://slack.com/api/chat.postMessage', { method: 'POST', @@ -55,13 +110,14 @@ export async function handlePROpened( } ); - const initialMessageData = await initialMessageResponse.json(); + const initialMessageData: { ok: boolean; ts: string } = + await initialMessageResponse.json(); if (!initialMessageData.ok) { throw new Error('Failed to send initial Slack message'); } - const messageTs = initialMessageData.ts; + const messageTs: string = initialMessageData.ts; const newPrBody = `Slack message_ts: ${messageTs}\n\n${prBody}`; const octokit = github.getOctokit(githubToken); @@ -71,17 +127,19 @@ export async function handlePROpened( body: newPrBody, }); - const commitsUrl = pr.commits_url; - const commitsResponse = await fetch(commitsUrl, { - headers: { - Authorization: `token ${githubToken}`, - }, - }); - - const commitsData = await commitsResponse.json(); + const { owner, repo } = github.context.repo; + core.info( + `Commits URL: https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/commits` + ); + const commitsData: Commit[] = await fetchAllCommits( + owner, + repo, + prNumber, + githubToken + ); - const repoUrl = `https://github.com/${github.context.repo.owner}/${github.context.repo.repo}`; - const commitMessages = commitsData + const repoUrl = `https://github.com/${owner}/${repo}`; + let commitMessages = commitsData .map((commit: Commit) => { const commitMessage = commit.commit.message.split('\n')[0]; // Extract only the first line const commitSha = commit.sha; @@ -95,24 +153,48 @@ export async function handlePROpened( }) .join('\n'); - const changelogUrl = `${repoUrl}/compare/${targetBranch}...${branchName}`; - const commitListMessage = commitListMessageTemplate - .replace('${commitListMessage}', commitMessages) - .replace('${changelogUrl}', changelogUrl) - .replace('${branchName}', branchName) - .replace('${targetBranch}', targetBranch) - .replace(/\\n/g, '\n'); // Replace escaped newline characters with actual newline characters - - await fetch('https://slack.com/api/chat.postMessage', { - method: 'POST', - headers: { - Authorization: `Bearer ${slackToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - channel: slackChannel, - text: commitListMessage, - thread_ts: messageTs, - }), - }); + if (commitMessages.length > 4000) { + // Slack message limit is 4000 characters + const commitMessagesArr = commitMessages.match(/[\s\S]{1,4000}/g) || []; + for (let i = 0; i < commitMessagesArr.length; i++) { + const text = + i === commitMessagesArr.length - 1 + ? `${commitMessagesArr[i]}\n\n<${repoUrl}/compare/${targetBranch}...${branchName}|Full Changelog: ${branchName} to ${targetBranch}>` + : commitMessagesArr[i]; + + await fetch('https://slack.com/api/chat.postMessage', { + method: 'POST', + headers: { + Authorization: `Bearer ${slackToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + channel: slackChannel, + text: text, + thread_ts: messageTs, + }), + }); + } + } else { + const changelogUrl = `${repoUrl}/compare/${targetBranch}...${branchName}`; + const commitListMessage = commitListMessageTemplate + .replace('${commitListMessage}', commitMessages) + .replace('${changelogUrl}', changelogUrl) + .replace('${branchName}', branchName) + .replace('${targetBranch}', targetBranch) + .replace(/\\n/g, '\n'); // Replace escaped newline characters with actual newline characters + + await fetch('https://slack.com/api/chat.postMessage', { + method: 'POST', + headers: { + Authorization: `Bearer ${slackToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + channel: slackChannel, + text: commitListMessage, + thread_ts: messageTs, + }), + }); + } }