Skip to content

Commit

Permalink
feat: send notifications to Slack
Browse files Browse the repository at this point in the history
  • Loading branch information
jsfez committed Nov 11, 2024
1 parent 81f9da4 commit cca6f45
Show file tree
Hide file tree
Showing 14 changed files with 321 additions and 17 deletions.
17 changes: 17 additions & 0 deletions apps/backend/db/migrations/20241107214041_project_slack_channel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* @param {import('knex').Knex} knex
*/
export const up = async (knex) => {
await knex.schema.alterTable("projects", async (table) => {
table.string("slackChannelId");
});
};

/**
* @param {import('knex').Knex} knex
*/
export const down = async (knex) => {
await knex.schema.alterTable("projects", async (table) => {
table.dropColumn("slackChannelId");
});
};
8 changes: 4 additions & 4 deletions apps/backend/db/seeds/seeds.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,27 +116,27 @@ export const seed = async (knex) => {
{
...timeStamps,
name: "big",
token: "big-xxx",
token: "big-650ded7d72e85b52e099df6e56aa204d4fe9",
accountId: smoothAccount.id,
private: false,
},
{
...timeStamps,
name: "awesome",
token: "awesome-xxx",
token: "awesome-650ded7d72e85b52e099df6e56aa204d",
accountId: helloAccount.id,
defaultBaseBranch: "main",
},
{
...timeStamps,
name: "zone-51",
token: "zone-51-xxx",
token: "zone-51-650ded7d72e85b52e099df6e56aa204d",
accountId: gregAccount.id,
},
{
...timeStamps,
name: "lalouland",
token: "lalouland-xxx",
token: "lalouland-650ded7d72e85b52e099df6e56aa20",
accountId: jeremyAccount.id,
},
]);
Expand Down
90 changes: 88 additions & 2 deletions apps/backend/src/build-notification/notifications.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { invariant } from "@argos/util/invariant";
import type { Octokit, RestEndpointMethodTypes } from "@octokit/rest";
import Bolt from "@slack/bolt";

import {
Build,
Expand All @@ -16,6 +17,7 @@ import { getGitlabClientFromAccount } from "@/gitlab/index.js";
import { UnretryableError } from "@/job-core/index.js";
import { getRedisLock } from "@/util/redis/index.js";

import { postMessageToSlackChannel } from "../slack/index.js";
import { getAggregatedNotification } from "./aggregated.js";
import { getCommentBody } from "./comment.js";
import { job as buildNotificationJob } from "./job.js";
Expand Down Expand Up @@ -214,11 +216,91 @@ const sendGitlabNotification = async (ctx: Context) => {
}
};

const sendSlackNotification = async (ctx: Context) => {
const { build, notification } = ctx;
invariant(build, "no build found", UnretryableError);

if (build.jobStatus !== "complete") {
return;
}

const { project, compareScreenshotBucket } = build;
invariant(
compareScreenshotBucket,
"no compare screenshot bucket found",
UnretryableError,
);
invariant(project, "no project found", UnretryableError);

const { account, slackChannelId } = project;

invariant(account, "no account found", UnretryableError);

if (!account.slackInstallation) {
return;
}

if (!slackChannelId) {
return;
}

const buildUrl = await build.getUrl();

await postMessageToSlackChannel({
installation: account.slackInstallation,
channel: slackChannelId,
text: notification.description,
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `*project ${project.name} | <${buildUrl}|Build ${build.number}: ${build.name}> of*\n*Visual Changes:* _${notification.description}_`,
},
accessory: {
type: "button",
text: {
type: "plain_text",
text: "Review Changes",
},
url: buildUrl,
action_id: "build-link",
},
},
{ type: "divider" },
{
type: "section",
fields: [
{ type: "mrkdwn", text: `*Type:* ${build.type}` },
{ type: "mrkdwn", text: `*Commit:* ${ctx.commit.slice(0, 7)}` },
{
type: "mrkdwn",
text: `*Branch:* ${build.compareScreenshotBucket?.branch}`,
},
{
type: "mrkdwn",
text: `*Screenshots Compared:* ${build.compareScreenshotBucket?.screenshotCount}`,
},
],
},
build.pullRequest?.number && {
type: "section",
fields: [
{
type: "mrkdwn",
text: `*Pull Request #<https://github.com/|#${build.pullRequest.number}:* ${build.pullRequest.title}>`,
},
],
},
].filter(Boolean) as Bolt.types.Block[],
});
};

export const processBuildNotification = async (
buildNotification: BuildNotification,
) => {
await buildNotification.$fetchGraph(
`build.[project.[gitlabProject, githubRepository.[githubAccount,repoInstallations.installation], account], compareScreenshotBucket]`,
`build.[project.[gitlabProject, githubRepository.[githubAccount,repoInstallations.installation], account.slackInstallation], compareScreenshotBucket]`,
);

invariant(buildNotification.build, "No build found", UnretryableError);
Expand Down Expand Up @@ -265,5 +347,9 @@ export const processBuildNotification = async (
aggregatedNotification,
};

await Promise.all([sendGithubNotification(ctx), sendGitlabNotification(ctx)]);
await Promise.all([
sendGithubNotification(ctx),
sendGitlabNotification(ctx),
sendSlackNotification(ctx),
]);
};
2 changes: 2 additions & 0 deletions apps/backend/src/database/models/Project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export class Project extends Model {
gitlabProjectId: { type: ["null", "string"] },
prCommentEnabled: { type: "boolean" },
summaryCheck: { type: "string", enum: ["always", "never", "auto"] },
slackChannelId: { type: ["null", "string"] },
},
});

Expand All @@ -63,6 +64,7 @@ export class Project extends Model {
gitlabProjectId!: string | null;
prCommentEnabled!: boolean;
summaryCheck!: "always" | "never" | "auto";
slackChannelId!: string | null;

override $formatDatabaseJson(json: Pojo) {
json = super.$formatDatabaseJson(json);
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/src/graphql/__generated__/resolver-types.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions apps/backend/src/graphql/__generated__/schema.gql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 12 additions & 4 deletions apps/backend/src/graphql/definitions/Project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ export const typeDefs = gql`
prCommentEnabled: Boolean!
"Summary check"
summaryCheck: SummaryCheck!
"Slack channel"
slackChannelId: String
"Build names"
buildNames: [String!]!
"Contributors"
Expand Down Expand Up @@ -157,6 +159,7 @@ export const typeDefs = gql`
private: Boolean
name: String
summaryCheck: SummaryCheck
slackChannelId: String
}
input TransferProjectInput {
Expand Down Expand Up @@ -550,9 +553,9 @@ export const resolvers: IResolvers = {
return null;
}

const permssions = await project.$getPermissions(ctx.auth?.user ?? null);
const permissions = await project.$getPermissions(ctx.auth?.user ?? null);

if (!permssions.includes("view")) {
if (!permissions.includes("view")) {
return null;
}

Expand All @@ -567,9 +570,9 @@ export const resolvers: IResolvers = {
return null;
}

const permssions = await project.$getPermissions(ctx.auth?.user ?? null);
const permissions = await project.$getPermissions(ctx.auth?.user ?? null);

if (!permssions.includes("view")) {
if (!permissions.includes("view")) {
return null;
}

Expand Down Expand Up @@ -618,6 +621,7 @@ export const resolvers: IResolvers = {
const project = await getAdminProject({
id: args.input.id,
user: ctx.auth?.user,
withGraphFetched: "account.slackInstallation",
});

const data: PartialModelObject<Project> = {};
Expand All @@ -638,6 +642,10 @@ export const resolvers: IResolvers = {
data.summaryCheck = args.input.summaryCheck;
}

if (args.input.slackChannelId != null) {
data.slackChannelId = args.input.slackChannelId;
}

if (args.input.name != null && project.name !== args.input.name) {
await checkGqlProjectName({
name: args.input.name,
Expand Down
27 changes: 26 additions & 1 deletion apps/backend/src/slack/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,13 @@ const receiver = new Bolt.ExpressReceiver({
clientId: config.get("slack.clientId"),
clientSecret: config.get("slack.clientSecret"),
stateSecret: config.get("slack.stateSecret"),
scopes: ["links:read", "links:write", "team:read"],
scopes: [
"links:read",
"links:write",
"team:read",
"chat:write",
"chat:write.public",
],
installationStore,
redirectUri: config.get("server.url") + "/auth/slack/oauth_redirect",
installerOptions: {
Expand Down Expand Up @@ -382,4 +388,23 @@ export async function uninstallSlackInstallation(
]);
}

/**
* Post a message to a Slack channel.
*/
export async function postMessageToSlackChannel({
installation,
channel,
text,
blocks,
}: {
installation: SlackInstallation;
channel: string;
text: string;
blocks?: Bolt.types.AnyBlock[];
}) {
const token = installation.installation.bot?.token;
invariant(token, "Expected bot token to be defined");
await boltApp.client.chat.postMessage({ token, channel, text, blocks });
}

export const slackMiddleware: Router = receiver.router;
2 changes: 1 addition & 1 deletion apps/frontend/src/containers/BuildModeIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export const BuildModeIndicator = memo(function BuildModeIndicator(props: {
>
<div
className={clsx(
"bg-app rounded-full border",
"bg-app self-start rounded-full border",
{ sm: "p-0.5", md: "p-1" }[scale],
)}
>
Expand Down
Loading

0 comments on commit cca6f45

Please sign in to comment.