deploy-vir 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,7 +22,7 @@ export default defineDeployVirConfig({
22
22
  {
23
23
  /** This does not need to match the actual repo name. */
24
24
  name: 'whatever',
25
- girUrl: 'git@github.com:electrovir/deploy-vir.git',
25
+ gitUrl: 'git@github.com:electrovir/deploy-vir.git',
26
26
  deploys: [
27
27
  {
28
28
  deployName: 'staging',
package/bin.js CHANGED
@@ -3,6 +3,6 @@
3
3
  import {runCliScript} from '@augment-vir/node/dist/index.js';
4
4
  import {join} from 'node:path';
5
5
 
6
- const cliPath = join(import.meta.dirname, 'src', 'cli.script.ts');
6
+ const cliPath = join(import.meta.dirname, 'src', 'cli', 'cli.script.ts');
7
7
 
8
8
  await runCliScript(cliPath, import.meta.filename, 'deploy-vir');
package/dist/cli/cli.js CHANGED
@@ -27,6 +27,7 @@ export async function runDeployVir(args, config) {
27
27
  const repoConfig = assertWrap.isDefined(config.repos.find((repo) => repo.name === args.repoName, `Failed to find a repo config by name '${args.repoName}'`));
28
28
  const branchConfig = assertWrap.isDefined(repoConfig.deploys.find((deploy) => deploy.deployName === args.deployName, `Failed to find a deploy named '${args.deployName}' in repo '${args.repoName}'.`));
29
29
  const remoteName = await getGitRemoteName(git, repoConfig);
30
+ log.faint(`Remote: ${remoteName}`);
30
31
  const deployCommits = await pushDeploy(git, branchConfig, remoteName);
31
32
  if (config.notifications?.length) {
32
33
  await sendNotifications(config.notifications, {
package/dist/config.d.ts CHANGED
@@ -71,7 +71,7 @@ export type DeployVirRepoConfig = {
71
71
  * - 'git@github.com:electrovir/deploy-vir.git'
72
72
  * - 'https://github.com/electrovir/deploy-vir.git'
73
73
  */
74
- girUrl: string;
74
+ gitUrl: string;
75
75
  /**
76
76
  * A url to the base commit path for links. The commit hash will be appended to this for links
77
77
  * in notifications. If this is omitted, there simply won't be any links.
package/dist/git.d.ts CHANGED
@@ -5,7 +5,7 @@ import { type DeployVirBranchConfig, type DeployVirRepoConfig } from './config.j
5
5
  *
6
6
  * @category Internal
7
7
  */
8
- export declare function getGitRemoteName(git: Readonly<SimpleGit>, repoConfig: Readonly<Pick<DeployVirRepoConfig, 'name' | 'girUrl'>>): Promise<string>;
8
+ export declare function getGitRemoteName(git: Readonly<SimpleGit>, repoConfig: Readonly<Pick<DeployVirRepoConfig, 'name' | 'gitUrl'>>): Promise<string>;
9
9
  /**
10
10
  * A git commit.
11
11
  *
package/dist/git.js CHANGED
@@ -9,9 +9,9 @@ import { KnownError } from './known.error.js';
9
9
  */
10
10
  export async function getGitRemoteName(git, repoConfig) {
11
11
  assert.isTruthy(repoConfig.name, 'Repo config name cannot be empty.');
12
- assert.isTruthy(repoConfig.girUrl, 'Repo git URL cannot be empty.');
12
+ assert.isTruthy(repoConfig.gitUrl, 'Repo git URL cannot be empty.');
13
13
  const remotes = await git.getRemotes(true);
14
- const remoteMatchByUrl = remotes.find((remote) => remote.refs.fetch === repoConfig.girUrl || remote.refs.push === repoConfig.girUrl);
14
+ const remoteMatchByUrl = remotes.find((remote) => remote.refs.fetch === repoConfig.gitUrl || remote.refs.push === repoConfig.gitUrl);
15
15
  if (remoteMatchByUrl) {
16
16
  return remoteMatchByUrl.name;
17
17
  }
@@ -22,7 +22,7 @@ export async function getGitRemoteName(git, repoConfig) {
22
22
  ]
23
23
  .filter(check.isTruthy)
24
24
  .join('-');
25
- await git.addRemote(newRemoteName, repoConfig.girUrl);
25
+ await git.addRemote(newRemoteName, repoConfig.gitUrl);
26
26
  return newRemoteName;
27
27
  }
28
28
  /**
@@ -34,6 +34,7 @@ export async function pushDeploy(git, { deployName, fromBranch, toBranch }, remo
34
34
  assert.isTruthy(fromBranch, `Deploy '${deployName}' fromBranch cannot be empty.`);
35
35
  assert.isTruthy(toBranch, `Deploy '${deployName}' toBranch cannot be empty.`);
36
36
  assert.isTruthy(remoteName, 'Remote name cannot be empty.');
37
+ log.faint(`Pushing ${remoteName}/${fromBranch} to ${toBranch}`);
37
38
  await git.fetch(remoteName, fromBranch);
38
39
  const pushString = `${remoteName}/${fromBranch}:${toBranch}`;
39
40
  /** Get commits that are on {@link toBranch} but not on {@link fromBranch}. */
@@ -26,10 +26,10 @@ export async function sendNotificationToSlack({ branchConfig, deployCommits: { d
26
26
  const deployedCommitBullets = deployedCommits.map((commit) => formatCommit(repoConfig.commitBaseUrl, commit));
27
27
  const overwrittenCommitBullets = overwrittenCommits.map((commit) => formatCommit(repoConfig.commitBaseUrl, commit));
28
28
  const deployedCommitBulletsText = deployedCommitBullets
29
- .map((bullet) => `• ${bullet}`)
29
+ .map((bullet) => `- ${bullet}`)
30
30
  .join('\n');
31
31
  const overwrittenCommitBulletsText = overwrittenCommitBullets.length
32
- ? overwrittenCommitBullets.map((bullet) => `• ${bullet}`).join('\n')
32
+ ? overwrittenCommitBullets.map((bullet) => `- ${bullet}`).join('\n')
33
33
  : undefined;
34
34
  const blocks = [
35
35
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deploy-vir",
3
- "version": "0.0.2",
3
+ "version": "0.1.0",
4
4
  "description": "Deploy a branch to another branch with notifications.",
5
5
  "keywords": [
6
6
  "deploy",
@@ -0,0 +1,3 @@
1
+ import {runDeployVirCli} from './cli.js';
2
+
3
+ await runDeployVirCli(process.argv, process.cwd());
package/src/cli/cli.ts ADDED
@@ -0,0 +1,81 @@
1
+ import {assertWrap} from '@augment-vir/assert';
2
+ import {extractErrorMessage, log} from '@augment-vir/common';
3
+ import simpleGit from 'simple-git';
4
+ import {type DeployVirConfig} from '../config.js';
5
+ import {getGitRemoteName, pushDeploy} from '../git.js';
6
+ import {KnownError} from '../known.error.js';
7
+ import {sendNotifications} from '../notifications/send-notifications.js';
8
+
9
+ /**
10
+ * Run the deploy-vir CLI.
11
+ *
12
+ * @category Internal
13
+ */
14
+ export async function runDeployVirCli(args: ReadonlyArray<string>, cwd = process.cwd()) {
15
+ const configPath = assertWrap.isTruthy(args[0], 'Missing config path.');
16
+ const repoName = assertWrap.isTruthy(args[1], 'Missing repo name.');
17
+ const deployName = assertWrap.isTruthy(args[2], 'Missing deploy name.');
18
+
19
+ const config = (await import(configPath)).default;
20
+
21
+ await runDeployVir({cwd, deployName, repoName}, config);
22
+ }
23
+
24
+ /**
25
+ * Args for {@link runDeployVir}.
26
+ *
27
+ * @category Internal
28
+ */
29
+ export type DeployVirArgs = {
30
+ /** This must match a repo name in your config. */
31
+ repoName: string;
32
+ /** This much match a deploy name in your config. */
33
+ deployName: string;
34
+ cwd: string;
35
+ };
36
+
37
+ /**
38
+ * Run a deploy.
39
+ *
40
+ * @category Main
41
+ */
42
+ export async function runDeployVir(
43
+ args: Readonly<DeployVirArgs>,
44
+ config: Readonly<DeployVirConfig>,
45
+ ) {
46
+ try {
47
+ const git = simpleGit(args.cwd);
48
+
49
+ const repoConfig = assertWrap.isDefined(
50
+ config.repos.find(
51
+ (repo) => repo.name === args.repoName,
52
+ `Failed to find a repo config by name '${args.repoName}'`,
53
+ ),
54
+ );
55
+ const branchConfig = assertWrap.isDefined(
56
+ repoConfig.deploys.find(
57
+ (deploy) => deploy.deployName === args.deployName,
58
+ `Failed to find a deploy named '${args.deployName}' in repo '${args.repoName}'.`,
59
+ ),
60
+ );
61
+ const remoteName = await getGitRemoteName(git, repoConfig);
62
+ log.faint(`Remote: ${remoteName}`);
63
+
64
+ const deployCommits = await pushDeploy(git, branchConfig, remoteName);
65
+
66
+ if (config.notifications?.length) {
67
+ await sendNotifications(config.notifications, {
68
+ branchConfig,
69
+ deployCommits,
70
+ repoConfig,
71
+ });
72
+ }
73
+ } catch (error) {
74
+ if (error instanceof KnownError) {
75
+ /** If its a known error, don't log the whole stack trace. */
76
+ log.error(extractErrorMessage(error));
77
+ } else {
78
+ log.error(error);
79
+ }
80
+ }
81
+ }
package/src/config.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The full deploy-vir config object.
3
+ *
4
+ * @category Config
5
+ */
6
+ export type DeployVirConfig = {
7
+ repos: DeployVirRepoConfig[];
8
+ notifications?: NotificationConfig[] | undefined;
9
+ };
10
+
11
+ /**
12
+ * All available targets for sending notifications to.
13
+ *
14
+ * @category Internal
15
+ */
16
+ export enum NotificationTarget {
17
+ Slack = 'slack',
18
+ }
19
+
20
+ /**
21
+ * Notification config for Slack.
22
+ *
23
+ * @category Internal
24
+ */
25
+ export type SlackNotificationConfig = {
26
+ target: NotificationTarget;
27
+ /**
28
+ * Find or create this by going to:
29
+ *
30
+ * Apps and workflows > Custom Integrations > Incoming WebHooks
31
+ */
32
+ webhookUrl: string;
33
+ /**
34
+ * An emoji string for the Webhook's message. If not provided, the Webhook integration's avatar
35
+ * that you set will be used.
36
+ */
37
+ avatarEmoji?: string | undefined;
38
+ /**
39
+ * A username for the Webhook's message. If not provided, the Webhook integration's avatar that
40
+ * you set will be used.
41
+ */
42
+ username?: string | undefined;
43
+ /** A channel for the Webhook's message. If not provided, the Webhook integration */
44
+ channelName: string | undefined;
45
+ /**
46
+ * By default the notification will list out all commits that are getting deployed. This will
47
+ * turn that off.
48
+ */
49
+ hideAllCommits?: boolean | undefined;
50
+ };
51
+
52
+ /**
53
+ * Config for a notification when a deploy runs.
54
+ *
55
+ * @category Config
56
+ */
57
+ // eslint-disable-next-line sonarjs/redundant-type-aliases
58
+ export type NotificationConfig = SlackNotificationConfig;
59
+
60
+ /**
61
+ * A repo config for deploying.
62
+ *
63
+ * @category Config
64
+ */
65
+ export type DeployVirRepoConfig = {
66
+ /**
67
+ * Does not need to match the actual repo name in GitHub, it can just be whatever name you want
68
+ * to call it for deploy-vir.
69
+ */
70
+ name: string;
71
+ /**
72
+ * A git URL to the repo.
73
+ *
74
+ * @example
75
+ *
76
+ * - 'git@github.com:electrovir/deploy-vir.git'
77
+ * - 'https://github.com/electrovir/deploy-vir.git'
78
+ */
79
+ gitUrl: string;
80
+ /**
81
+ * A url to the base commit path for links. The commit hash will be appended to this for links
82
+ * in notifications. If this is omitted, there simply won't be any links.
83
+ *
84
+ * @example
85
+ *
86
+ * - 'https://github.com/electrovir/deploy-vir/commit'
87
+ */
88
+ commitBaseUrl?: string | undefined;
89
+ deploys: DeployVirBranchConfig[];
90
+ };
91
+
92
+ /**
93
+ * A deploy configuration.
94
+ *
95
+ * @category Config
96
+ */
97
+ export type DeployVirBranchConfig = {
98
+ deployName: string;
99
+ fromBranch: string;
100
+ toBranch: string;
101
+ };
102
+
103
+ /**
104
+ * Define a type safe deploy-vir config.
105
+ *
106
+ * @category Internal
107
+ */
108
+ export function defineDeployVirConfig(config: Readonly<DeployVirConfig>) {
109
+ return config;
110
+ }
package/src/git.ts ADDED
@@ -0,0 +1,142 @@
1
+ import {assert, check} from '@augment-vir/assert';
2
+ import {extractErrorMessage, log, logColors} from '@augment-vir/common';
3
+ import {askQuestion} from '@augment-vir/node';
4
+ import {type DefaultLogFields, type ListLogLine, type SimpleGit} from 'simple-git';
5
+ import {type DeployVirBranchConfig, type DeployVirRepoConfig} from './config.js';
6
+ import {KnownError} from './known.error.js';
7
+
8
+ /**
9
+ * Find the git remote's name, or create a new one.
10
+ *
11
+ * @category Internal
12
+ */
13
+ export async function getGitRemoteName(
14
+ git: Readonly<SimpleGit>,
15
+ repoConfig: Readonly<Pick<DeployVirRepoConfig, 'name' | 'gitUrl'>>,
16
+ ): Promise<string> {
17
+ assert.isTruthy(repoConfig.name, 'Repo config name cannot be empty.');
18
+ assert.isTruthy(repoConfig.gitUrl, 'Repo git URL cannot be empty.');
19
+
20
+ const remotes = await git.getRemotes(true);
21
+
22
+ const remoteMatchByUrl = remotes.find(
23
+ (remote) =>
24
+ remote.refs.fetch === repoConfig.gitUrl || remote.refs.push === repoConfig.gitUrl,
25
+ );
26
+
27
+ if (remoteMatchByUrl) {
28
+ return remoteMatchByUrl.name;
29
+ }
30
+
31
+ const remoteMatchByNameCount: number = remotes.reduce(
32
+ (count, remote) => count + (remote.name === repoConfig.name ? 1 : 0),
33
+ 0,
34
+ );
35
+
36
+ const newRemoteName = [
37
+ repoConfig.name,
38
+ remoteMatchByNameCount,
39
+ ]
40
+ .filter(check.isTruthy)
41
+ .join('-');
42
+
43
+ await git.addRemote(newRemoteName, repoConfig.gitUrl);
44
+
45
+ return newRemoteName;
46
+ }
47
+
48
+ /**
49
+ * A git commit.
50
+ *
51
+ * @category Internal
52
+ */
53
+ export type Commit = DefaultLogFields & ListLogLine;
54
+
55
+ /**
56
+ * All commits involved in a deploy.
57
+ *
58
+ * @category Internal
59
+ */
60
+ export type DeployCommits = {
61
+ deployedCommits: ReadonlyArray<Readonly<Commit>>;
62
+ overwrittenCommits: ReadonlyArray<Readonly<Commit>>;
63
+ };
64
+
65
+ /**
66
+ * Push the deploy via git.
67
+ *
68
+ * @category Internal
69
+ */
70
+ export async function pushDeploy(
71
+ git: Readonly<SimpleGit>,
72
+ {deployName, fromBranch, toBranch}: Readonly<DeployVirBranchConfig>,
73
+ remoteName: string,
74
+ ): Promise<DeployCommits> {
75
+ assert.isTruthy(fromBranch, `Deploy '${deployName}' fromBranch cannot be empty.`);
76
+ assert.isTruthy(toBranch, `Deploy '${deployName}' toBranch cannot be empty.`);
77
+ assert.isTruthy(remoteName, 'Remote name cannot be empty.');
78
+
79
+ log.faint(`Pushing ${remoteName}/${fromBranch} to ${toBranch}`);
80
+
81
+ await git.fetch(remoteName, fromBranch);
82
+
83
+ const pushString = `${remoteName}/${fromBranch}:${toBranch}`;
84
+
85
+ /** Get commits that are on {@link toBranch} but not on {@link fromBranch}. */
86
+ const commitsAhead = (
87
+ await git.log({
88
+ from: `${remoteName}/${toBranch}`,
89
+ to: `${remoteName}/${fromBranch}`,
90
+ })
91
+ ).all;
92
+
93
+ if (!commitsAhead.length) {
94
+ throw new KnownError('No commit diff: nothing to deploy!');
95
+ }
96
+
97
+ try {
98
+ await git.push(remoteName, pushString);
99
+
100
+ return {
101
+ deployedCommits: commitsAhead,
102
+ overwrittenCommits: [],
103
+ };
104
+ } catch (error) {
105
+ log.error(`Push failed: ${extractErrorMessage(error)}`);
106
+
107
+ /** Get commits that are on {@link toBranch} but not on {@link fromBranch}. */
108
+ const commitsBehind = await git.log({
109
+ from: `${remoteName}/${fromBranch}`,
110
+ to: `${remoteName}/${toBranch}`,
111
+ });
112
+
113
+ if (commitsBehind.total > 0) {
114
+ log.info(
115
+ `\nThe following ${commitsBehind.total} commit${commitsBehind.total === 1 ? '' : 's'} are on ${logColors.bold}${toBranch}${logColors.reset} but not on ${logColors.bold}${fromBranch}${logColors.reset}:`,
116
+ );
117
+ commitsBehind.all.forEach((commit, index) => {
118
+ log.faint(
119
+ ` ${index + 1}. ${commit.hash.slice(0, 7)} (${commit.author_name}) - ${commit.message}`,
120
+ );
121
+ });
122
+ }
123
+
124
+ const shouldForcePush = await askQuestion(
125
+ `\nDo you want to force push ${logColors.bold}${fromBranch}${logColors.reset} to ${logColors.bold}${toBranch}${logColors.reset}?\n\n${logColors.warning}This will overwrite the commits listed above.${logColors.reset} (y/N): `,
126
+ );
127
+
128
+ if (shouldForcePush.toLowerCase() === 'y' || shouldForcePush.toLowerCase() === 'yes') {
129
+ await git.push(remoteName, pushString, ['--force']);
130
+ log.warning(
131
+ `Force pushed ${logColors.bold}${fromBranch}${logColors.reset} to ${logColors.bold}${toBranch}${logColors.reset}.`,
132
+ );
133
+ } else {
134
+ throw new KnownError(`Deploy aborted.`);
135
+ }
136
+
137
+ return {
138
+ deployedCommits: commitsAhead,
139
+ overwrittenCommits: commitsBehind.all,
140
+ };
141
+ }
142
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from './cli/cli.js';
2
+ export * from './config.js';
3
+ export * from './git.js';
4
+ export * from './known.error.js';
5
+ export * from './notifications/notification-params.js';
6
+ export * from './notifications/send-notifications.js';
7
+ export * from './notifications/slack.js';
@@ -0,0 +1,8 @@
1
+ /**
2
+ * A known error that won't get its full stack trace logged.
3
+ *
4
+ * @category Internal
5
+ */
6
+ export class KnownError extends Error {
7
+ public override readonly name = 'KnownError';
8
+ }
@@ -0,0 +1,19 @@
1
+ import {
2
+ type DeployVirBranchConfig,
3
+ type DeployVirRepoConfig,
4
+ type NotificationConfig,
5
+ type NotificationTarget,
6
+ } from '../config.js';
7
+ import {type DeployCommits} from '../git.js';
8
+
9
+ /**
10
+ * Params for sending a notification.
11
+ *
12
+ * @category Internal
13
+ */
14
+ export type NotificationParams<Target extends NotificationTarget = NotificationTarget> = {
15
+ deployCommits: Readonly<DeployCommits>;
16
+ branchConfig: Readonly<DeployVirBranchConfig>;
17
+ repoConfig: Readonly<Pick<DeployVirRepoConfig, 'commitBaseUrl'>>;
18
+ notification: Readonly<Extract<NotificationConfig, {target: Target}>>;
19
+ };
@@ -0,0 +1,32 @@
1
+ import {type NotificationConfig, NotificationTarget} from '../config.js';
2
+ import {KnownError} from '../known.error.js';
3
+ import {type NotificationParams} from './notification-params.js';
4
+ import {sendNotificationToSlack} from './slack.js';
5
+
6
+ /**
7
+ * Send all configured notifications.
8
+ *
9
+ * @category Internal
10
+ */
11
+ export async function sendNotifications(
12
+ notifications: ReadonlyArray<Readonly<NotificationConfig>>,
13
+ {branchConfig, deployCommits, repoConfig}: Readonly<Omit<NotificationParams, 'notification'>>,
14
+ ) {
15
+ await Promise.all(
16
+ notifications.map(async (notification) => {
17
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
18
+ if (notification.target === NotificationTarget.Slack) {
19
+ await sendNotificationToSlack({
20
+ deployCommits,
21
+ branchConfig,
22
+ repoConfig,
23
+ notification,
24
+ });
25
+ } else {
26
+ throw new KnownError(
27
+ `Unexpected notification target: '${String(notification.target)}'`,
28
+ );
29
+ }
30
+ }),
31
+ );
32
+ }
@@ -0,0 +1,120 @@
1
+ import {addPrefix} from '@augment-vir/common';
2
+ import {type ChatPostMessageArguments} from '@slack/web-api';
3
+ import {joinUrlPaths} from 'url-vir';
4
+ import {type SlackNotificationConfig} from '../config.js';
5
+ import {type Commit} from '../git.js';
6
+ import {type NotificationParams} from './notification-params.js';
7
+
8
+ export {type ChatPostMessageArguments} from '@slack/web-api';
9
+
10
+ function truncateString(value: string, truncateAt = 100): string {
11
+ if (value.length > truncateAt) {
12
+ return `${value.slice(0, Math.max(0, truncateAt))}...`;
13
+ } else {
14
+ return value;
15
+ }
16
+ }
17
+
18
+ function formatCommit(baseCommitUrl: string | undefined, commit: Readonly<Commit>) {
19
+ const commitLine = `${commit.hash.slice(0, 7)} (${commit.author_name}) - ${truncateString(commit.message)}`;
20
+
21
+ if (baseCommitUrl) {
22
+ return `<${joinUrlPaths(baseCommitUrl, commit.hash)}|${commitLine}>`;
23
+ } else {
24
+ return commitLine;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Send a notification to Slack.
30
+ *
31
+ * @category Internal
32
+ */
33
+ export async function sendNotificationToSlack({
34
+ branchConfig,
35
+ deployCommits: {deployedCommits, overwrittenCommits},
36
+ notification,
37
+ repoConfig,
38
+ }: Readonly<NotificationParams>) {
39
+ const deployedCommitBullets = deployedCommits.map((commit) =>
40
+ formatCommit(repoConfig.commitBaseUrl, commit),
41
+ );
42
+ const overwrittenCommitBullets = overwrittenCommits.map((commit) =>
43
+ formatCommit(repoConfig.commitBaseUrl, commit),
44
+ );
45
+
46
+ const deployedCommitBulletsText = deployedCommitBullets
47
+ .map((bullet) => `- ${bullet}`)
48
+ .join('\n');
49
+
50
+ const overwrittenCommitBulletsText = overwrittenCommitBullets.length
51
+ ? overwrittenCommitBullets.map((bullet) => `- ${bullet}`).join('\n')
52
+ : undefined;
53
+
54
+ const blocks: any[] = [
55
+ {
56
+ type: 'section',
57
+ text: {
58
+ type: 'mrkdwn',
59
+ text: `*${branchConfig.deployName}* Deployed`,
60
+ },
61
+ },
62
+ {
63
+ type: 'section',
64
+ text: {
65
+ type: 'mrkdwn',
66
+ text: `\n*Deployed Commits*\n${deployedCommitBulletsText}`,
67
+ },
68
+ },
69
+ ...(overwrittenCommitBulletsText
70
+ ? [
71
+ {
72
+ type: 'section',
73
+ text: {
74
+ type: 'mrkdwn',
75
+ text: `\n*Overwritten Commits*\n${overwrittenCommitBulletsText}`,
76
+ },
77
+ },
78
+ ]
79
+ : []),
80
+ ];
81
+
82
+ await sendSlackMessage(notification, {
83
+ attachments: [],
84
+ channel: addPrefix({value: notification.channelName, prefix: '#'}),
85
+ blocks,
86
+ });
87
+ }
88
+
89
+ async function sendSlackMessage(
90
+ slackConfig: Readonly<Pick<SlackNotificationConfig, 'avatarEmoji' | 'webhookUrl' | 'username'>>,
91
+ body: Readonly<ChatPostMessageArguments>,
92
+ ) {
93
+ try {
94
+ const response = await fetch(slackConfig.webhookUrl, {
95
+ method: 'POST',
96
+ headers: {
97
+ 'Content-Type': 'application/json',
98
+ },
99
+ body: JSON.stringify({
100
+ ...body,
101
+ ...(slackConfig.avatarEmoji
102
+ ? {
103
+ icon_emoji: slackConfig.avatarEmoji,
104
+ }
105
+ : {}),
106
+ ...(slackConfig.username
107
+ ? {
108
+ username: slackConfig.username,
109
+ }
110
+ : {}),
111
+ }),
112
+ });
113
+
114
+ if (!response.ok) {
115
+ throw new Error(`Failed to send Slack webhook: ${response.status}`);
116
+ }
117
+ } catch (error) {
118
+ console.error('Error sending Slack message:', error);
119
+ }
120
+ }