diff --git a/.env.test b/.env.test index efa12a8..a28e760 100644 --- a/.env.test +++ b/.env.test @@ -9,3 +9,4 @@ SLACK_BOT_USER_O_AUTH_ACCESS_TOKEN=SLACK_BOT_USER_O_AUTH_ACCESS_TOKEN SLACK_SIGNING_SECRET=SLACK_SIGNING_SECRET EMAIL_DOMAINS=my-domain.com,ext.my-domain.com GITLAB_URL=https://my-git.domain.com +TICKET_MANAGEMENT_URL_PATTERN=https://my-ticket-management.com/view/{ticketId} diff --git a/README.md b/README.md index f153725..a91b4a8 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Here are the available commands: | Command | Description | | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/homer changelog` | Display changelogs, for any Gitlab project, between 2 release tags. | | `/homer project add ` | Add a Gitlab project to a channel. | | `/homer project list` | List the Gitlab projects added to a channel. | | `/homer project remove` | Remove a Gitlab project from a channel. | @@ -213,6 +214,11 @@ Create a `.env` file containing the following variables: The gitlab URL of your organization +- `TICKET_MANAGEMENT_URL_PATTERN` + + The ticket management URL pattern for your organization, this is used to generate a link to the ticket in the changelog. + It must contain the `{ticketId}` matcher to be replaced by the ticket ID, for instance `https://my-ticket-management.com/view/{ticketId}`. + If you want Homer to connect to an **external PostgreSQL database**, you can set the following variables: diff --git a/src/changelog/buildChangelogModalView.ts b/src/changelog/buildChangelogModalView.ts new file mode 100644 index 0000000..44d37fe --- /dev/null +++ b/src/changelog/buildChangelogModalView.ts @@ -0,0 +1,181 @@ +import type { Block, KnownBlock, View } from '@slack/types'; +import { generateChangelog } from '@/changelog/utils/generateChangelog'; +import { slackifyChangelog } from '@/changelog/utils/slackifyChangelog'; +import { getProjectsByChannelId } from '@/core/services/data'; +import { fetchProjectById, fetchProjectTags } from '@/core/services/gitlab'; +import type { SlackOption } from '@/core/typings/SlackOption'; + +interface ChangelogModalData { + channelId?: string; + projectId?: number; + projectOptions?: SlackOption[]; + releaseTagName?: string; +} + +export async function buildChangelogModalView({ + channelId, + projectId, + projectOptions, + releaseTagName, +}: ChangelogModalData): Promise { + if (channelId !== undefined && projectOptions === undefined) { + const dataProjects = await getProjectsByChannelId(channelId); + const projects = await Promise.all( + dataProjects.map(async (dataProject) => + fetchProjectById(Number(dataProject.projectId)) + ) + ); + + projectOptions = projects + .sort((a, b) => + a.path_with_namespace.localeCompare(b.path_with_namespace) + ) + .map((project) => ({ + text: { + type: 'plain_text', + text: project.path_with_namespace, + }, + value: project.id.toString(), + })) as SlackOption[]; + } + + if (!projectOptions || projectOptions.length === 0) { + throw new Error( + 'No Gitlab project has been found on this channel :homer-stressed:' + ); + } + + if (projectId === undefined) { + projectId = parseInt(projectOptions[0].value, 10); + } + + const tags = (await fetchProjectTags(projectId)).slice(0, 3); + const previousReleaseTagName = releaseTagName ?? tags[0]?.name; + const changelog = previousReleaseTagName + ? await generateChangelog(projectId, previousReleaseTagName) + : ''; + + const previousReleaseOptions = tags.map(({ name }) => ({ + text: { + type: 'plain_text', + text: name, + }, + value: name, + })) as SlackOption[]; + + return { + type: 'modal', + callback_id: 'changelog-modal', + title: { + type: 'plain_text', + text: 'Changelog', + }, + submit: { + type: 'plain_text', + text: 'Ok', + }, + notify_on_close: false, + blocks: [ + { + type: 'input', + block_id: 'changelog-project-block', + dispatch_action: true, + element: { + type: 'static_select', + action_id: 'changelog-select-project-action', + options: projectOptions, + initial_option: projectOptions?.[0], + placeholder: { + type: 'plain_text', + text: 'Select the project', + }, + }, + label: { + type: 'plain_text', + text: 'Project', + }, + }, + previousReleaseOptions.length > 0 + ? [ + { + type: 'input', + block_id: 'changelog-release-tag-block', + dispatch_action: true, + element: { + type: 'static_select', + action_id: 'changelog-select-release-tag-action', + initial_option: previousReleaseOptions[0], + options: previousReleaseOptions, + placeholder: { + type: 'plain_text', + text: 'Select the previous release tag', + }, + }, + label: { + type: 'plain_text', + text: 'Previous release tag', + }, + }, + { + type: 'context', + block_id: 'changelog-release-tag-info-block', + elements: [ + { + type: 'plain_text', + text: 'This should be changed only if the previous release has been aborted.', + }, + ], + }, + ] + : [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: '*Previous release tag*', + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: 'No previous release tag has been found.', + }, + }, + ], + { + type: 'section', + block_id: 'changelog-preview-title-block', + text: { + type: 'mrkdwn', + text: '*Preview*', + }, + }, + { + type: 'section', + block_id: 'changelog-preview-block', + text: { + type: 'mrkdwn', + text: changelog + ? slackifyChangelog(changelog) + : 'No change has been found.', + }, + }, + changelog && { + type: 'input', + block_id: 'changelog-markdown-block', + label: { + type: 'plain_text', + text: 'Markdown', + }, + element: { + type: 'plain_text_input', + multiline: true, + initial_value: changelog, + }, + }, + ] + .flat() + .filter(Boolean) as (KnownBlock | Block)[], + }; +} diff --git a/src/changelog/changelogBlockActionsHandler.ts b/src/changelog/changelogBlockActionsHandler.ts new file mode 100644 index 0000000..4d11f22 --- /dev/null +++ b/src/changelog/changelogBlockActionsHandler.ts @@ -0,0 +1,27 @@ +import { logger } from '@/core/services/logger'; +import type { BlockActionsPayload } from '@/core/typings/BlockActionPayload'; +import { updateChangelog } from './utils/updateChangelog'; +import { updateChangelogProject } from './utils/updateChangelogProject'; + +export async function changelogBlockActionsHandler( + payload: BlockActionsPayload +): Promise { + await Promise.all( + payload.actions.map(async (action) => { + const { action_id } = action; + + switch (action_id) { + case 'changelog-select-project-action': + return updateChangelogProject(payload); + + case 'changelog-select-release-tag-action': + return updateChangelog(payload); + + default: + logger.error( + new Error(`Unknown changelog block action: ${action_id}`) + ); + } + }) + ); +} diff --git a/src/changelog/changelogRequestHandler.ts b/src/changelog/changelogRequestHandler.ts new file mode 100644 index 0000000..4c8892f --- /dev/null +++ b/src/changelog/changelogRequestHandler.ts @@ -0,0 +1,27 @@ +import type { Response } from 'express'; +import { GENERIC_ERROR_MESSAGE, HTTP_STATUS_NO_CONTENT } from '@/constants'; +import { slackBotWebClient } from '@/core/services/slack'; +import type { SlackExpressRequest } from '@/core/typings/SlackSlashCommand'; +import { buildChangelogModalView } from './buildChangelogModalView'; + +export async function changelogRequestHandler( + req: SlackExpressRequest, + res: Response +) { + const { channel_id, trigger_id, user_id } = req.body; + + res.sendStatus(HTTP_STATUS_NO_CONTENT); + + try { + await slackBotWebClient.views.open({ + trigger_id, + view: await buildChangelogModalView({ channelId: channel_id }), + }); + } catch (error) { + await slackBotWebClient.chat.postEphemeral({ + channel: channel_id, + user: user_id, + text: error instanceof Error ? error.message : GENERIC_ERROR_MESSAGE, + }); + } +} diff --git a/src/changelog/utils/generateChangelog.ts b/src/changelog/utils/generateChangelog.ts new file mode 100644 index 0000000..682e52f --- /dev/null +++ b/src/changelog/utils/generateChangelog.ts @@ -0,0 +1,187 @@ +import { + fetchMergeRequestByIid, + fetchMergeRequestCommits, + fetchProjectCommit, + fetchProjectCommits, + fetchProjectCommitsSince, + fetchProjectTag, +} from '@/core/services/gitlab'; +import type { GitlabCommit } from '@/core/typings/GitlabCommit'; +import type { GitlabMergeRequestDetails } from '@/core/typings/GitlabMergeRequest'; +import { getEnvVariable } from '@/core/utils/getEnvVariable'; + +const DEFAULT_BRANCHES = ['main', 'master', 'release']; +const TICKET_ID_MATCHER = '{ticketId}'; + +export async function generateChangelog( + projectId: number, + previousReleaseTagName: string | undefined, + filter: (commit: GitlabCommit) => boolean = () => true +): Promise { + let commits: GitlabCommit[]; + + if (previousReleaseTagName !== undefined) { + const previousReleaseTag = await fetchProjectTag( + projectId, + previousReleaseTagName + ); + + commits = await fetchProjectCommitsSince( + projectId, + new Date( + new Date(previousReleaseTag.commit.created_at).getTime() + 1000 + ).toISOString() + ); + } else { + commits = await fetchProjectCommits(projectId); + } + + const mergeRequestCommitsMaps = ( + await Promise.all( + commits + .filter(filterDefaultBranchMergeCommits) + .map(async (mergeCommit) => + generateMergeRequestCommitsMap(projectId, mergeCommit) + ) + ) + ).flat(); + + if (commits.length > 0 && mergeRequestCommitsMaps.length === 0) { + return commits + .filter(filter) + .map(({ message, title }) => { + const ticketId = getTicketId(message); + + return `- ${title}${ + ticketId + ? ` - [${ticketId}](${getEnvVariable( + 'TICKET_MANAGEMENT_URL_PATTERN' + ).replace(TICKET_ID_MATCHER, ticketId)})` + : '' + }`; + }) + .join('\n'); + } + + return removeDuplicatedCommits(mergeRequestCommitsMaps) + .filter(({ mergeRequestCommit }) => filter(mergeRequestCommit)) + .map(({ mergeRequest, mergeRequestCommit }) => { + const ticketId = getTicketId(mergeRequestCommit.message); + + return `- [${mergeRequestCommit.title}](${mergeRequest.web_url})${ + ticketId + ? ` - [${ticketId}](${getEnvVariable( + 'TICKET_MANAGEMENT_URL_PATTERN' + ).replace(TICKET_ID_MATCHER, ticketId)})` + : '' + }`; + }) + .join('\n'); +} + +interface MergeRequestCommitMap { + mergeRequest: GitlabMergeRequestDetails; + mergeRequestCommit: GitlabCommit; +} + +function filterDefaultBranchMergeCommits({ message }: GitlabCommit): boolean { + return ( + DEFAULT_BRANCHES.some((branch) => message.includes(`into '${branch}'`)) && + message.includes('See merge request') + ); +} + +async function generateMergeRequestCommitsMap( + projectId: number, + mergeCommit: GitlabCommit +): Promise { + const { message } = mergeCommit; + const mergeRequestIid = parseInt( + message.match(/See merge request.*!(\d+)/)?.[1] ?? '', + 10 + ); + + if (!Number.isInteger(mergeRequestIid)) { + throw new Error( + `Unable to retrieve merge request iid of merge commit ${message}` + ); + } + + const mergeRequest = await fetchMergeRequestByIid(projectId, mergeRequestIid); + const { squash, squash_commit_sha } = mergeRequest; + + if (squash && !squash_commit_sha) { + throw new Error( + `Gitlab API did not provide squash_commit_sha for merge request ${mergeRequestIid} of project ${projectId}` + ); + } + + if (squash_commit_sha) { + return [ + { + mergeRequest, + mergeRequestCommit: await fetchProjectCommit( + projectId, + squash_commit_sha + ), + }, + ]; + } + + const mergeRequestCommits = await fetchMergeRequestCommits( + projectId, + mergeRequestIid + ); + + return ( + await Promise.all( + mergeRequestCommits.map(async (commit) => { + if (commit.message.includes('See merge request')) { + return generateMergeRequestCommitsMap(projectId, commit); + } + return { + mergeRequest, + mergeRequestCommit: commit, + }; + }) + ) + ).flat(); +} + +function getTicketId(commitMessage: string): string | undefined { + return commitMessage + .match(/[A-Z]+-[0-9]+/i)?.[0] + ?.trim() + ?.toUpperCase(); +} + +/** + * When using release branches, the "same" commits will come either from the + * release branch and the original one, so we list duplicates and keep the + * oldest one. + */ +function removeDuplicatedCommits( + mergeRequestCommitsMaps: MergeRequestCommitMap[] +): MergeRequestCommitMap[] { + // + return mergeRequestCommitsMaps.filter((mergeRequestCommitsMap) => { + // We list all the mergeRequestCommitsMaps with the same commit message + const duplicatedMaps = mergeRequestCommitsMaps.filter( + (map) => + map.mergeRequestCommit.message === + mergeRequestCommitsMap.mergeRequestCommit.message + ); + + // We pick the oldest one + const oldestDuplicatedMap = duplicatedMaps.reduce((mapToKeep, map) => + new Date(map.mergeRequestCommit.created_at).getTime() < + new Date(mapToKeep.mergeRequestCommit.created_at).getTime() + ? map + : mapToKeep + ); + + // We keep only the oldest mergeRequestCommitsMap with this commit + // message + return mergeRequestCommitsMap === oldestDuplicatedMap; + }); +} diff --git a/src/changelog/utils/slackifyChangelog.ts b/src/changelog/utils/slackifyChangelog.ts new file mode 100644 index 0000000..f6d04b2 --- /dev/null +++ b/src/changelog/utils/slackifyChangelog.ts @@ -0,0 +1,19 @@ +import slackifyMarkdown from 'slackify-markdown'; + +const SLACK_CHARACTER_LIMIT = 3000; + +export function slackifyChangelog(changelog: string): string { + let slackifiedChangelog = slackifyMarkdown(changelog); + + // Slack allows only 3000 characters in text field + if (slackifiedChangelog.length > SLACK_CHARACTER_LIMIT) { + slackifiedChangelog = slackifiedChangelog + .slice(0, SLACK_CHARACTER_LIMIT) + .split('\n') + .slice(0, -2) + .join('\n'); + + slackifiedChangelog = `${slackifiedChangelog}\n\n*⚠️ Changelog truncated due to Slack limitations.*`; + } + return slackifiedChangelog; +} diff --git a/src/changelog/utils/updateChangelog.ts b/src/changelog/utils/updateChangelog.ts new file mode 100644 index 0000000..b7f936e --- /dev/null +++ b/src/changelog/utils/updateChangelog.ts @@ -0,0 +1,56 @@ +import type { InputBlock, StaticSelect } from '@slack/web-api'; +import { slackBotWebClient } from '@/core/services/slack'; +import type { BlockActionsPayload } from '@/core/typings/BlockActionPayload'; +import type { SlackOption } from '@/core/typings/SlackOption'; +import { cleanViewState } from '@/core/utils/cleanViewState'; +import { buildChangelogModalView } from '../buildChangelogModalView'; + +export async function updateChangelog(payload: BlockActionsPayload) { + const { blocks, callback_id, id, state, submit, title, type } = payload.view; + const currentView = { blocks, callback_id, submit, title, type }; + const releaseTagInfoBlockIndex = blocks.findIndex( + (block) => block.block_id === 'changelog-release-tag-info-block' + ); + + if (releaseTagInfoBlockIndex !== -1) { + blocks.splice(releaseTagInfoBlockIndex + 1); + cleanViewState(payload.view); + } + + currentView.blocks.push({ + type: 'section', + text: { + type: 'plain_text', + text: ':loader:', + }, + }); + + const projectId = parseInt( + state.values['changelog-project-block']?.['changelog-select-project-action'] + ?.selected_option?.value, + 10 + ); + + const releaseTagName = + state.values['changelog-release-tag-block']?.[ + 'changelog-select-release-tag-action' + ]?.selected_option?.value; + + const viewPromise = buildChangelogModalView({ + projectId, + projectOptions: ((blocks[0] as InputBlock).element as StaticSelect) + .options as SlackOption[], + releaseTagName, + }); + + // Loader + await slackBotWebClient.views.update({ + view_id: id, + view: currentView, + }); + + await slackBotWebClient.views.update({ + view_id: id, + view: await viewPromise, + }); +} diff --git a/src/changelog/utils/updateChangelogProject.ts b/src/changelog/utils/updateChangelogProject.ts new file mode 100644 index 0000000..2672874 --- /dev/null +++ b/src/changelog/utils/updateChangelogProject.ts @@ -0,0 +1,15 @@ +import type { BlockActionsPayload } from '@/core/typings/BlockActionPayload'; +import { cleanViewState } from '@/core/utils/cleanViewState'; +import { updateChangelog } from './updateChangelog'; + +export async function updateChangelogProject(payload: BlockActionsPayload) { + const { blocks } = payload.view; + const projectBlockIndex = blocks.findIndex( + (block) => block.block_id === 'changelog-project-block' + ); + + blocks.splice(projectBlockIndex + 1); + cleanViewState(payload.view); + + await updateChangelog(payload); +} diff --git a/src/core/requestHandlers/commandRequestHandler.ts b/src/core/requestHandlers/commandRequestHandler.ts index b247eb3..80339bd 100644 --- a/src/core/requestHandlers/commandRequestHandler.ts +++ b/src/core/requestHandlers/commandRequestHandler.ts @@ -1,4 +1,5 @@ import type { Response } from 'express'; +import { changelogRequestHandler } from '@/changelog/changelogRequestHandler'; import { projectRequestHandler } from '@/project/projectRequestHandler'; import { reviewRequestHandler } from '@/review/reviewRequestHandler'; import type { SlackExpressRequest } from '../typings/SlackSlashCommand'; @@ -12,6 +13,9 @@ export async function commandRequestHandler( const command = text?.split(' ')?.[0]; switch (command) { + case 'changelog': + return changelogRequestHandler(req, res); + case 'project': return projectRequestHandler(req, res); diff --git a/src/core/requestHandlers/interactiveRequestHandler.ts b/src/core/requestHandlers/interactiveRequestHandler.ts index b6ff9de..2313176 100644 --- a/src/core/requestHandlers/interactiveRequestHandler.ts +++ b/src/core/requestHandlers/interactiveRequestHandler.ts @@ -1,5 +1,6 @@ import type { Request, Response } from 'express'; import { HTTP_STATUS_NO_CONTENT } from '@/constants'; +import { viewSubmissionRequestHandler } from '@/core/requestHandlers/interactiveRequestHandlers/viewSubmissionRequestHandler'; import { logger } from '@/core/services/logger'; import { blockActionsRequestHandler } from './interactiveRequestHandlers/blockActionsRequestHandler'; @@ -14,6 +15,9 @@ export async function interactiveRequestHandler( case 'block_actions': return blockActionsRequestHandler(req, res, payload); + case 'view_submission': + return viewSubmissionRequestHandler(req, res, payload); + default: logger.error(new Error(`Unknown interactive type: ${type}`)); res.sendStatus(HTTP_STATUS_NO_CONTENT); diff --git a/src/core/requestHandlers/interactiveRequestHandlers/blockActionsRequestHandler.ts b/src/core/requestHandlers/interactiveRequestHandlers/blockActionsRequestHandler.ts index 33d3362..069196d 100644 --- a/src/core/requestHandlers/interactiveRequestHandlers/blockActionsRequestHandler.ts +++ b/src/core/requestHandlers/interactiveRequestHandlers/blockActionsRequestHandler.ts @@ -1,4 +1,5 @@ import type { Request, Response } from 'express'; +import { changelogBlockActionsHandler } from '@/changelog/changelogBlockActionsHandler'; import { HTTP_STATUS_OK } from '@/constants'; import { logger } from '@/core/services/logger'; import type { BlockActionsPayloadWithChannel } from '@/core/typings/BlockActionPayload'; @@ -21,6 +22,9 @@ export async function blockActionsRequestHandler( } switch (true) { + case action_id.startsWith('changelog'): + return changelogBlockActionsHandler(payload); + case action_id.startsWith('project'): return projectBlockActionsHandler(payload); diff --git a/src/core/requestHandlers/interactiveRequestHandlers/viewSubmissionRequestHandler.ts b/src/core/requestHandlers/interactiveRequestHandlers/viewSubmissionRequestHandler.ts new file mode 100644 index 0000000..d069bef --- /dev/null +++ b/src/core/requestHandlers/interactiveRequestHandlers/viewSubmissionRequestHandler.ts @@ -0,0 +1,20 @@ +import type { Request, Response } from 'express'; +import { HTTP_STATUS_NO_CONTENT } from '@/constants'; +import { logger } from '@/core/services/logger'; +import type { ModalViewSubmissionPayload } from '@/core/typings/ModalViewSubmissionPayload'; + +export async function viewSubmissionRequestHandler( + req: Request, + res: Response, + payload: ModalViewSubmissionPayload +): Promise { + const { callback_id } = payload.view; + + if (callback_id === undefined || callback_id === 'changelog-modal') { + res.sendStatus(HTTP_STATUS_NO_CONTENT); + return; + } + + res.sendStatus(HTTP_STATUS_NO_CONTENT); + logger.error(new Error(`Unknown view callback id: ${callback_id}`)); +} diff --git a/src/core/typings/ModalViewSubmissionPayload.ts b/src/core/typings/ModalViewSubmissionPayload.ts new file mode 100644 index 0000000..acb91eb --- /dev/null +++ b/src/core/typings/ModalViewSubmissionPayload.ts @@ -0,0 +1,34 @@ +import type { ModalView } from '@slack/web-api'; + +interface SubmittedModalView extends ModalView { + app_id: string; + app_installed_team_id: string; + bot_id: string; + hash: string; + id: string; + previous_view_id: null; + root_view_id: string; + state: { values: any }; + team_id: string; +} + +export interface ModalViewSubmissionPayload { + api_app_id: string; + response_urls: { + block_id: string; + action_id: string; + channel_id: string; + response_url: string; + }[]; + team: { id: string; domain: string }; + token: string; + trigger_id: string; + type: 'view_submission'; + user: { + id: string; + name: string; + team_id: string; + username: string; + }; + view: SubmittedModalView; +} diff --git a/src/core/viewBuilders/buildHelpMessage.ts b/src/core/viewBuilders/buildHelpMessage.ts index 885694c..07b0580 100644 --- a/src/core/viewBuilders/buildHelpMessage.ts +++ b/src/core/viewBuilders/buildHelpMessage.ts @@ -7,6 +7,7 @@ export function buildHelpMessage(channelId: string): ChatPostMessageArguments { text: `\ Here are the available commands: +- /homer changelog - Display changelogs, for any Gitlab project, between 2 release tags. - /homer project add <project_name|project_id> - Add a Gitlab project to a channel. - /homer project list - List the Gitlab projects added to a channel. - /homer project remove - Remove a Gitlab project from a channel. @@ -22,6 +23,7 @@ Don't hesitate to join me on #moes-tavern-homer to take a beer!`, text: `\ Here are the available commands: +• \`/homer changelog\` Display changelogs, for any Gitlab project, between 2 release tags. • \`/homer project add <project_name|project_id>\` Add a Gitlab project to a channel. • \`/homer project list\` List the Gitlab projects added to a channel. • \`/homer project remove\` Remove a Gitlab project from a channel.