CodeCommitのPull Request関連のイベントをSlackに通知してみた

CodeCommitのイベントがEventBridgeルールを介してAWS Chatbotに通知できなかったので、EventBridgeルールとLambda関数を使ってCodeCommitのPull Request関連のイベントをSlackに通知してみました。
2022.01.12

Pull Requestのターゲットブランチ毎に通知するチャンネルを変更したい

こんにちは、のんピ(@non____97)です。

CodeCommitでPull Requestしたらすぐレビューしてもらうために通知して欲しいですよね? 私もそう思います。

CodeCommitとAWS Chatbotを連携させればPull Requestに限らず色々なイベントをSlackに通知できます。

しかし、Codeシリーズの通知機能である「Notifications」だと、Pull Requestのターゲットブランチ毎に通知するチャンネルを変更することといったことはできません。

CodeCommitのNotifications設定

ここで、「複雑な条件を設定したいなら、EventBridgeルールとAWS Chatbotを連携させれば良いんじゃない?」と思う方もいらっしゃるかもしれませんが、以下記事の中で検証されている通り、このような構成ではSlackに通知することができません。

AWS公式ドキュメントでも「CloudWatch Alarms、CodeBuild、CodeCommit、CodeDeploy、CodePipelineからのイベント通知は、現在EventBridgeルールを介してサポートされていません。」と記載されています。

Event notifications from: CloudWatch Alarms, CodeBuild, CodeCommit, CodeDeploy, and CodePipeline are not currently supported via EventBridge rules. If you want to receive notifications for one of these services, you can go to its console, and configure Amazon SNS notifications that you can then map to your Slack channel or Amazon Chime webhook configuration in AWS Chatbot. For more information, see Amazon CloudWatch alarms or Notifications for AWS developer tools.

Using AWS Chatbot with other AWS services - Amazon EventBridge

これではターゲットブランチ毎にレビュアーが異なるときに、ちょっと面倒です。

そこで、以下記事を参考に、EventBridgeルールとLambda関数を使ってCodeCommitのPull Request関連のイベントをSlackに通知してみます。

いきなりまとめ

  • 2022年1月現在、CloudWatchアラームとCodeシリーズのイベントをEventBridgeルールを介してAWS Chatbotで通知することはできない
  • AWS Chatbot経由ではなくLambda関数からSlackに通知することで、通知内容をカスタマイズできる

検証の構成

今回の検証の構成は以下の通りです。

構成図

developブランチへのPull Request関連のイベントはアプリチームのSlackチャンネル、mainブランチへのPull Request関連のイベントはアプリチームとインフラチームのSlackチャンネルに通知するように設定します。

Incoming Webhooksアプリの作成

SlackにWebhookでメッセージ通知をするためにIncoming Webhooksアプリを作成します。

まず、Slack APIのYour Appsにアクセスします。

新規にIncoming Webhooksアプリを作成するのでCreate New Appをクリックします。

Create New App

From scratchをクリックします。 From scratch

Incoming Webhooksアプリの名前とSlackワークスペースを入力・選択し、Create Appをクリックします。

Create App

Incoming WebhooksからActivate Incoming WebhooksOnにします。その後、Add New Webhook to Workspaceをクリックします。

Activate Incoming Webhooks

通知先のチャンネル(アプリチームのチャンネル)を選択して、許可するをクリックします。

通知先のチャンネル選択

Webhook URLが追加されました。

アプリチーム用Webhook URLの追加確認

同様の手順でインフラチームのチャンネルへの通知用Webhook URLも追加します。

インフラチーム用Webhook URLの追加確認

以上でSlack側の下準備は完了です。

CodeCommitのPull Request関連のイベントをSlackに通知するLambda関数

今回の対象のイベントは以下の7つです。

  • commentOnPullRequestCreated Event
  • commentOnPullRequestUpdated Event
  • pullRequestCreated Event
  • pullRequestSourceBranchUpdated Event
  • pullRequestStatusChanged Event
  • pullRequestMergeStatusUpdated Event
  • pullRequestApprovalStateChanged Event

以下AWS公式ドキュメントを参考にして各イベントの構造を確認します。

確認したイベントの構造を元に、イベント発火時にLambda関数に渡されるオブジェクトを定義します。Lambda関数に渡されるオブジェクトには発生したイベントの情報であるoriginalEventと通知先情報であるnoticeTargetsを定義しました。こちらのオブジェクトの整形はEventBridgeルールのInput Transformerで行います。

./src/lambda/functions/notice-pull-request-events.ts

interface CommentOnPullRequestCreatedDetailEvent {
  afterCommitId: string;
  beforeCommitId: string;
  callerUserArn: string;
  commentId: string;
  displayName: string;
  emailAddress: string;
  event: string;
  inReplyTo: string;
  notificationBody: string;
  pullRequestId: string;
  repositoryId: string;
  repositoryName: string;
}

interface CommentOnPullRequestUpdatedDetailEvent {
  afterCommitId: string;
  beforeCommitId: string;
  callerUserArn: string;
  commentId: string;
  event: string;
  inReplyTo: string;
  notificationBody: string;
  pullRequestId: string;
  repositoryId: string;
  repositoryName: string;
}

interface PullRequestDetailEvent {
  author: string;
  callerUserArn: string;
  creationDate: string;
  description: string;
  destinationCommit: string;
  destinationReference: string;
  event: string;
  isMerged: string;
  lastModifiedDate: string;
  notificationBody: string;
  pullRequestId: string;
  pullRequestStatus: string;
  repositoryNames: string[];
  revisionId: string;
  sourceCommit: string;
  sourceReference: string;
  title: string;
}

interface PullRequestMergeStatusUpdatedDetailEvent {
  author: string;
  callerUserArn: string;
  creationDate: string;
  description: string;
  destinationCommit: string;
  destinationReference: string;
  event: string;
  isMerged: string;
  lastModifiedDate: string;
  mergeOption: string;
  notificationBody: string;
  pullRequestId: string;
  pullRequestStatus: string;
  repositoryNames: string[];
  revisionId: string;
  sourceCommit: string;
  sourceReference: string;
  title: string;
}

interface PullRequestApprovalStateChangedDetailEvent {
  approvalStatus: string;
  author: string;
  callerUserArn: string;
  creationDate: string;
  description: string;
  destinationCommit: string;
  destinationReference: string;
  event: string;
  isMerged: string;
  lastModifiedDate: string;
  notificationBody: string;
  pullRequestId: string;
  pullRequestStatus: string;
  repositoryNames: string[];
  revisionId: string;
  sourceCommit: string;
  sourceReference: string;
  title: string;
}

interface CodeCommitEvent {
  originalEvent: {
    version: string;
    id: string;
    "detail-type": string;
    source: string;
    account: string;
    time: string;
    region: string;
    resources: string[];
    detail:
      | CommentOnPullRequestCreatedDetailEvent
      | CommentOnPullRequestUpdatedDetailEvent
      | PullRequestDetailEvent
      | PullRequestMergeStatusUpdatedDetailEvent
      | PullRequestApprovalStateChangedDetailEvent;
  };
  noticeTargets: { [key: string]: string[] }[];
}

対象イベントのPull Request IDから詳細な情報を取得し、Slackに通知するメッセージを構成していきます。

Slackに通知するメッセージの構成はSlackの公式ドキュメントを参考に、タイトルや段組み、対象Pull Requestへのリンクを記載するようにしました。

全体のコードは以下の通りです。

./src/lambda/functions/notice-pull-request-events.ts

import {
  CodeCommitClient,
  GetPullRequestCommand,
  GetCommentsForPullRequestCommand,
} from "@aws-sdk/client-codecommit";
import axios, { AxiosRequestConfig, AxiosResponse, AxiosError } from "axios";

// Ref: https://docs.aws.amazon.com/ja_jp/codecommit/latest/userguide/monitoring-events.html#pullRequestCreated
// - commentOnPullRequestCreated Event
// - commentOnPullRequestUpdated Event
// - pullRequestCreated Event
// - pullRequestSourceBranchUpdated Event
// - pullRequestStatusChanged Event
// - pullRequestMergeStatusUpdated Event
// - pullRequestApprovalStateChanged Event

interface CommentOnPullRequestCreatedDetailEvent {
  afterCommitId: string;
  beforeCommitId: string;
  callerUserArn: string;
  commentId: string;
  displayName: string;
  emailAddress: string;
  event: string;
  inReplyTo: string;
  notificationBody: string;
  pullRequestId: string;
  repositoryId: string;
  repositoryName: string;
}

interface CommentOnPullRequestUpdatedDetailEvent {
  afterCommitId: string;
  beforeCommitId: string;
  callerUserArn: string;
  commentId: string;
  event: string;
  inReplyTo: string;
  notificationBody: string;
  pullRequestId: string;
  repositoryId: string;
  repositoryName: string;
}

interface PullRequestDetailEvent {
  author: string;
  callerUserArn: string;
  creationDate: string;
  description: string;
  destinationCommit: string;
  destinationReference: string;
  event: string;
  isMerged: string;
  lastModifiedDate: string;
  notificationBody: string;
  pullRequestId: string;
  pullRequestStatus: string;
  repositoryNames: string[];
  revisionId: string;
  sourceCommit: string;
  sourceReference: string;
  title: string;
}

interface PullRequestMergeStatusUpdatedDetailEvent {
  author: string;
  callerUserArn: string;
  creationDate: string;
  description: string;
  destinationCommit: string;
  destinationReference: string;
  event: string;
  isMerged: string;
  lastModifiedDate: string;
  mergeOption: string;
  notificationBody: string;
  pullRequestId: string;
  pullRequestStatus: string;
  repositoryNames: string[];
  revisionId: string;
  sourceCommit: string;
  sourceReference: string;
  title: string;
}

interface PullRequestApprovalStateChangedDetailEvent {
  approvalStatus: string;
  author: string;
  callerUserArn: string;
  creationDate: string;
  description: string;
  destinationCommit: string;
  destinationReference: string;
  event: string;
  isMerged: string;
  lastModifiedDate: string;
  notificationBody: string;
  pullRequestId: string;
  pullRequestStatus: string;
  repositoryNames: string[];
  revisionId: string;
  sourceCommit: string;
  sourceReference: string;
  title: string;
}

interface CodeCommitEvent {
  originalEvent: {
    version: string;
    id: string;
    "detail-type": string;
    source: string;
    account: string;
    time: string;
    region: string;
    resources: string[];
    detail:
      | CommentOnPullRequestCreatedDetailEvent
      | CommentOnPullRequestUpdatedDetailEvent
      | PullRequestDetailEvent
      | PullRequestMergeStatusUpdatedDetailEvent
      | PullRequestApprovalStateChangedDetailEvent;
  };
  noticeTargets: { [key: string]: string[] }[];
}

interface SlackMessgage {
  blocks: {
    type: string;
    block_id?: string;
    text?: { type: string; text: string };
    fields?: { type: string; text: string }[];
  }[];
}

// Number of characters limit for slack
// Ref: https://api.slack.com/reference/block-kit/blocks#section_fields
const characterLimit = 2000;

const requestSlack = async (
  slackWebhookUrl: string,
  slackMessage: SlackMessgage
) => {
  return new Promise<AxiosResponse | AxiosError>((resolve, reject) => {
    // Request parameters
    const options: AxiosRequestConfig = {
      url: slackWebhookUrl,
      method: "POST",
      headers: {
        "Content-type": "application/json",
      },
      data: slackMessage,
    };

    // Request Slack
    axios(options)
      .then((response) => {
        console.log(
          `response data : ${JSON.stringify(response.data, null, 2)}`
        );
        resolve(response);
      })
      .catch((error) => {
        console.error(`response error : ${JSON.stringify(error, null, 2)}`);
        reject(error);
      });
  });
};

export const handler = async (
  event: CodeCommitEvent
): Promise<AxiosResponse | AxiosError | void> => {
  if (!process.env["REGION"]) {
    console.log(
      `The region name environment variable (REGION_NAME) is not specified.
      e.g. us-east-1`
    );
    return;
  }

  console.log(`event : ${JSON.stringify(event, null, 2)}`);

  const region: string = process.env["REGION"];

  const codeCommitclient = new CodeCommitClient({ region: region });

  const getPullRequestCommandOutput = await codeCommitclient.send(
    new GetPullRequestCommand({
      pullRequestId: event.originalEvent.detail.pullRequestId,
    })
  );

  const slackMessage: SlackMessgage = {
    blocks: [
      {
        type: "header",
        block_id: "header",
        text: {
          type: "plain_text",
          text: "",
        },
      },
      {
        type: "divider",
      },
      {
        type: "section",
        block_id: "fieldsSection",
        fields: new Array(),
      },
      {
        type: "section",
        block_id: "textSection",
        text: {
          type: "mrkdwn",
          text: "",
        },
      },
    ],
  };

  const headerIndex = slackMessage.blocks.findIndex(
    (block) => block.block_id === "header"
  );
  const fieldsSectionIndex = slackMessage.blocks.findIndex(
    (block) => block.block_id === "fieldsSection"
  );
  const textSectionIndex = slackMessage.blocks.findIndex(
    (block) => block.block_id === "textSection"
  );

  const repositoryName =
    "repositoryNames" in event.originalEvent.detail
      ? event.originalEvent.detail.repositoryNames.toString()
      : event.originalEvent.detail.repositoryName
      ? event.originalEvent.detail.repositoryName
      : "undifined";

  const notificationBody = event.originalEvent.detail.notificationBody;
  const callerUserArn = event.originalEvent.detail.callerUserArn;
  const destinationReference =
    getPullRequestCommandOutput.pullRequest?.pullRequestTargets?.find(
      (pullRequestTarget) => pullRequestTarget.destinationReference
    )?.destinationReference;
  const consoleUrl = notificationBody.substring(
    notificationBody.indexOf("https://")
  );

  slackMessage.blocks[fieldsSectionIndex].fields?.push({
    type: "mrkdwn",
    text: `*AWS Management Console URL:*\n${consoleUrl}`,
  });

  slackMessage.blocks[fieldsSectionIndex].fields?.push({
    type: "mrkdwn",
    text: `*Caller User ARN:*\n${callerUserArn}`,
  });

  slackMessage.blocks[
    textSectionIndex
  ].text!.text = `\`\`\`${notificationBody.substring(
    0,
    characterLimit - 1
  )}\`\`\``;

  slackMessage.blocks[fieldsSectionIndex].fields?.push({
    type: "mrkdwn",
    text: `*Pull Request ID:*\n${getPullRequestCommandOutput.pullRequest?.pullRequestId}`,
  });

  slackMessage.blocks[fieldsSectionIndex].fields?.push({
    type: "mrkdwn",
    text: `*Pull Request Title:*\n${getPullRequestCommandOutput.pullRequest?.title?.substring(
      0,
      characterLimit - 1
    )}`,
  });

  slackMessage.blocks[fieldsSectionIndex].fields?.push({
    type: "mrkdwn",
    text: `*Pull Request Status:*\n${getPullRequestCommandOutput.pullRequest?.pullRequestStatus}`,
  });

  slackMessage.blocks[fieldsSectionIndex].fields?.push({
    type: "mrkdwn",
    text: `*isMerged Status:*\n${
      getPullRequestCommandOutput.pullRequest?.pullRequestTargets?.find(
        (pullRequestTarget) => pullRequestTarget.mergeMetadata
      )?.mergeMetadata?.isMerged
    }`,
  });

  slackMessage.blocks[fieldsSectionIndex].fields?.push({
    type: "mrkdwn",
    text: `*Destination Reference:*\n${destinationReference?.substring(
      0,
      characterLimit - 1
    )}`,
  });

  slackMessage.blocks[fieldsSectionIndex].fields?.push({
    type: "mrkdwn",
    text: `*Source Reference:*\n${getPullRequestCommandOutput.pullRequest?.pullRequestTargets
      ?.find((pullRequestTarget) => pullRequestTarget.sourceReference)
      ?.sourceReference?.substring(0, characterLimit - 1)}`,
  });

  if ("commentId" in event.originalEvent.detail) {
    const getCommentsForPullRequestCommandOutput = await codeCommitclient.send(
      new GetCommentsForPullRequestCommand({
        pullRequestId: event.originalEvent.detail.pullRequestId,
      })
    );

    slackMessage.blocks[fieldsSectionIndex].fields?.push({
      type: "mrkdwn",
      text: `*File:*\n${getCommentsForPullRequestCommandOutput.commentsForPullRequestData
        ?.find((pullRequest) =>
          pullRequest.comments?.find(
            (comment) =>
              "commentId" in event.originalEvent.detail &&
              comment.commentId === event.originalEvent.detail.commentId
          )
        )
        ?.location?.filePath?.substring(0, characterLimit - 1)}`,
    });

    slackMessage.blocks[fieldsSectionIndex].fields?.push({
      type: "mrkdwn",
      text: `*Comment:*\n${getCommentsForPullRequestCommandOutput.commentsForPullRequestData
        ?.find((pullRequest) =>
          pullRequest.comments?.find(
            (comment) =>
              "commentId" in event.originalEvent.detail &&
              comment.commentId === event.originalEvent.detail.commentId
          )
        )
        ?.comments?.find(
          (comment) =>
            "commentId" in event.originalEvent.detail &&
            comment.commentId === event.originalEvent.detail.commentId
        )
        ?.content?.substring(0, characterLimit - 1)}`,
    });
  }

  if ("approvalStatus" in event.originalEvent.detail) {
    slackMessage.blocks[fieldsSectionIndex].fields?.push({
      type: "mrkdwn",
      text: `*Approval Status:*\n${event.originalEvent.detail.approvalStatus}`,
    });
  }

  switch (event.originalEvent.detail.event) {
    case "commentOnPullRequestCreated":
      slackMessage.blocks[
        headerIndex
      ].text!.text = `The pull request in the ${repositoryName} repository has been commented`;
      break;
    case "commentOnPullRequestUpdated":
      slackMessage.blocks[
        headerIndex
      ].text!.text = `The ${repositoryName} repository pull request comments has been updated`;
      break;
    case "pullRequestCreated":
      slackMessage.blocks[
        headerIndex
      ].text!.text = `The pull request has been created in the ${repositoryName} repository`;
      break;
    case "pullRequestSourceBranchUpdated":
      slackMessage.blocks[
        headerIndex
      ].text!.text = `The source branch of the pull request in the ${repositoryName} repository has been updated`;
      break;
    case "pullRequestStatusChanged":
      slackMessage.blocks[
        headerIndex
      ].text!.text = `The status of the ${repositoryName} repository pull request has changed`;
      break;
    case "pullRequestMergeStatusUpdated":
      slackMessage.blocks[
        headerIndex
      ].text!.text = `The merge status of the ${repositoryName} repository pull request has changed`;
      break;
    case "pullRequestApprovalStateChanged":
      slackMessage.blocks[
        headerIndex
      ].text!.text = `The approval status of the ${repositoryName} repository pull request has changed`;
      break;
  }

  console.log(`slackMessage : ${JSON.stringify(slackMessage, null, 2)}`);

  for (const [key, slackWebhookUrls] of Object.entries(
    event.noticeTargets.find(
      (noticeTarget) => destinationReference! in noticeTarget
    )!
  )) {
    for (const [index, slackWebhookUrl] of slackWebhookUrls.entries()) {
      await requestSlack(slackWebhookUrl, slackMessage);
    }
  }
};

AWS CDKの構成

AWS CDKのディレクトリ構成とpackage.json

AWS CDKのディレクトリ構成は以下の通りです。

AWS CDKのディレクトリ構成

> tree
.
├── .gitignore
├── .npmignore
├── README.md
├── bin
│   └── notice-pull-request-events.ts
├── cdk.json
├── jest.config.js
├── lib
│   ├── notice-pull-request-events-function-stack.ts
│   ├── notice-pull-request-events-stack.ts
│   └── role-and-approve-rule-template-stack.ts
├── package-lock.json
├── package.json
├── src
│   └── lambda
│       └── functions
│           └── notice-pull-request-events.ts
├── test
│   └── notice-pull-request-events.test.ts
└── tsconfig.json

6 directories, 14 files

package.jsonは以下の通りです。AWS CDKのバージョンは2.5.0です。

package.json

{
  "name": "notice-pull-request-events",
  "version": "0.1.0",
  "bin": {
    "notice-pull-request-events": "bin/notice-pull-request-events.js"
  },
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "test": "jest",
    "cdk": "cdk"
  },
  "devDependencies": {
    "@types/jest": "^26.0.10",
    "@types/node": "10.17.27",
    "aws-cdk": "2.5.0",
    "jest": "^26.4.2",
    "ts-jest": "^26.2.0",
    "ts-node": "^9.0.0",
    "typescript": "~3.9.7"
  },
  "dependencies": {
    "@aws-sdk/client-codecommit": "^3.46.0",
    "aws-cdk-lib": "2.5.0",
    "axios": "^0.24.0",
    "constructs": "^10.0.0",
    "source-map-support": "^0.5.16"
  }
}

IAMロールと承認ルールテンプレートのスタック

Pull Requestの承認時の動作確認を行いたいので、IAMロールと承認ルールテンプレートを作成します。こちらのスタックは以前作成した以下記事で紹介しているものとほぼ同じです。(違いは承認ルールテンプレートでdevelopブランチも承認対象になっている程度です)

実際のコードは以下の通りです。

./lib/role-and-approve-rule-template-stack.ts

import { Construct } from "constructs";
import {
  Stack,
  StackProps,
  aws_iam as iam,
  aws_logs as logs,
  custom_resources as cr,
} from "aws-cdk-lib";

export class RoleAndApproveRuleTemplateStack extends Stack {
  public readonly approvalRuleTemplate: cr.AwsCustomResource;

  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const jumpAccountId: string = this.node.tryGetContext("jumpAccountId");

    // Create Infra Team IAM role
    const iamRole = new iam.Role(this, "IamRole", {
      assumedBy: new iam.AccountPrincipal(jumpAccountId).withConditions({
        Bool: {
          "aws:MultiFactorAuthPresent": "true",
        },
      }),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName("AdministratorAccess"),
      ],
    });

    this.approvalRuleTemplate = new cr.AwsCustomResource(
      this,
      "ApprovalRuleTemplate",
      {
        logRetention: logs.RetentionDays.ONE_WEEK,
        onCreate: {
          action: "createApprovalRuleTemplate",
          parameters: {
            approvalRuleTemplateContent: JSON.stringify({
              Version: "2018-11-08",
              DestinationReferences: ["refs/heads/main", "refs/heads/develop"],
              Statements: [
                {
                  Type: "Approvers",
                  NumberOfApprovalsNeeded: 1,
                  ApprovalPoolMembers: [
                    `arn:aws:sts::${this.account}:assumed-role/${iamRole.roleName}/*`,
                  ],
                },
              ],
            }),
            approvalRuleTemplateDescription:
              "Approval rule template for the main branch",
            approvalRuleTemplateName: "approvalRuleTemplate",
          },
          physicalResourceId: cr.PhysicalResourceId.fromResponse(
            "approvalRuleTemplate.approvalRuleTemplateId"
          ),
          service: "CodeCommit",
        },
        onUpdate: {
          action: "updateApprovalRuleTemplateContent",
          parameters: {
            newRuleContent: JSON.stringify({
              Version: "2018-11-08",
              DestinationReferences: ["refs/heads/main", "refs/heads/develop"],
              Statements: [
                {
                  Type: "Approvers",
                  NumberOfApprovalsNeeded: 1,
                  ApprovalPoolMembers: [
                    `arn:aws:sts::${this.account}:assumed-role/${iamRole.roleName}/*`,
                  ],
                },
              ],
            }),
            approvalRuleTemplateName: "approvalRuleTemplate",
          },
          physicalResourceId: cr.PhysicalResourceId.fromResponse(
            "approvalRuleTemplate.approvalRuleTemplateId"
          ),
          service: "CodeCommit",
        },
        onDelete: {
          action: "deleteApprovalRuleTemplate",
          parameters: {
            approvalRuleTemplateName: "approvalRuleTemplate",
          },
          service: "CodeCommit",
        },
        policy: cr.AwsCustomResourcePolicy.fromStatements([
          new iam.PolicyStatement({
            actions: [
              "codecommit:CreateApprovalRuleTemplate",
              "codecommit:UpdateApprovalRuleTemplateContent",
              "codecommit:DeleteApprovalRuleTemplate",
            ],
            resources: ["*"],
          }),
        ]),
      }
    );
  }
}

CodeCommitのPull Request関連のイベントをSlackに通知するLambda関数のスタック

CodeCommitのPull Request関連のイベントをSlackに通知するLambda関数をデプロイするためのスタックも準備します。Lambda関数で使用するAPIの権限許可を忘れがちなので忘れないようにします。(私は2回忘れました)

実際のコードは以下の通りです。

./lib/notice-pull-request-events-function-stack.ts

import { Construct } from "constructs";
import {
  Stack,
  StackProps,
  aws_iam as iam,
  aws_lambda as lambda,
  aws_lambda_nodejs as nodejs,
} from "aws-cdk-lib";

export class NoticePullRequestEventsFunctionStack extends Stack {
  public readonly noticePullRequestEventsFunction: nodejs.NodejsFunction;

  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const noticePullRequestEventsFunctionIamPolicy = new iam.ManagedPolicy(
      this,
      "NoticePullRequestEventsFunctionIamPolicy",
      {
        statements: [
          new iam.PolicyStatement({
            effect: iam.Effect.ALLOW,
            actions: [
              "codecommit:GetPullRequest",
              "codecommit:GetCommentsForPullRequest",
            ],
            resources: [`arn:aws:codecommit:${this.region}:${this.account}:*`],
          }),
        ],
      }
    );

    // Create an IAM role for Lambda functions.
    const noticePullRequestEventsFunctionIamRole = new iam.Role(
      this,
      "NoticePullRequestEventsFunctionIamRole",
      {
        assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
        managedPolicies: [
          iam.ManagedPolicy.fromAwsManagedPolicyName(
            "service-role/AWSLambdaBasicExecutionRole"
          ),
          noticePullRequestEventsFunctionIamPolicy,
        ],
      }
    );

    // Lambda function
    this.noticePullRequestEventsFunction = new nodejs.NodejsFunction(
      this,
      "NoticePullRequestEventsFunction",
      {
        entry: "src/lambda/functions/notice-pull-request-events.ts",
        runtime: lambda.Runtime.NODEJS_14_X,
        bundling: {
          minify: true,
        },
        environment: {
          REGION: this.region,
        },
        role: noticePullRequestEventsFunctionIamRole,
      }
    );
  }
}

CodeCommitリポジトリとEventBridgeルールのスタック

CodeCommitリポジトリと、Pull Request関連のイベントを検知するためのEventBridgeルールを作成します。

EventBridgeルール作成部分以外は以下記事で紹介しているものと同じです。

EventBridgeルールでは検知対象のイベントを指定してあげます。また、Input TransformerでLambda関数に渡すオブジェクトを以下のように構成します。

{
    "originalEvent": {
      // 発生したイベントのオブジェクト
    },
    "noticeTargets": [
        {
            "refs/heads/develop": [
                // アプリチームのSlackチャンネルへのWebhook URL
            ]
        },
        {
            "refs/heads/main": [
                // アプリチームのSlackチャンネルへのWebhook URL
                // インフラチームのSlackチャンネルへのWebhook URL
            ]
        }
    ]
}

実際のコードは以下の通りです。

./lib/notice-pull-request-events-stack.ts

import { Construct } from "constructs";
import {
  Stack,
  StackProps,
  aws_logs as logs,
  aws_iam as iam,
  aws_codecommit as codecommit,
  custom_resources as cr,
  aws_events as events,
  aws_events_targets as targets,
  aws_lambda_nodejs as nodejs,
} from "aws-cdk-lib";
interface NoticePullRequestEventsStackProps extends StackProps {
  approvalRuleTemplate: cr.AwsCustomResource;
  noticePullRequestEventsFunction: nodejs.NodejsFunction;
  appTeamWebhookUrl: string;
  infraTeamWebhookUrl: string;
}

export class NoticePullRequestEventsStack extends Stack {
  constructor(
    scope: Construct,
    id: string,
    props: NoticePullRequestEventsStackProps
  ) {
    super(scope, id, props);

    const repository = new codecommit.Repository(this, "Repository", {
      repositoryName: "CodeCommitTestRepository",
    });

    new cr.AwsCustomResource(
      this,
      "AssociateApprovalRuleTemplateWithRepository",
      {
        logRetention: logs.RetentionDays.ONE_WEEK,
        onCreate: {
          action: "associateApprovalRuleTemplateWithRepository",
          parameters: {
            approvalRuleTemplateName:
              props.approvalRuleTemplate.getResponseFieldReference(
                "approvalRuleTemplate.approvalRuleTemplateName"
              ),
            repositoryName: repository.repositoryName,
          },
          physicalResourceId: cr.PhysicalResourceId.of(
            `${props.approvalRuleTemplate.getResponseFieldReference(
              "approvalRuleTemplate.approvalRuleTemplateName"
            )}-${repository.repositoryName}`
          ),
          service: "CodeCommit",
        },
        onDelete: {
          action: "disassociateApprovalRuleTemplateFromRepository",
          parameters: {
            approvalRuleTemplateName:
              props.approvalRuleTemplate.getResponseFieldReference(
                "approvalRuleTemplate.approvalRuleTemplateName"
              ),
            repositoryName: repository.repositoryName,
          },
          service: "CodeCommit",
        },
        policy: cr.AwsCustomResourcePolicy.fromStatements([
          new iam.PolicyStatement({
            actions: [
              "codecommit:AssociateApprovalRuleTemplateWithRepository",
              "codecommit:DisassociateApprovalRuleTemplateFromRepository",
            ],
            resources: ["*"],
          }),
        ]),
      }
    );

    new events.Rule(this, "PullRequestEventBriedgeRule", {
      eventPattern: {
        source: ["aws.codecommit"],
        detail: {
          event: [
            "commentOnPullRequestCreated",
            "commentOnPullRequestUpdated",
            "pullRequestCreated",
            "pullRequestSourceBranchUpdated",
            "pullRequestStatusChanged",
            "pullRequestMergeStatusUpdated",
            "pullRequestApprovalStateChanged",
          ],
        },
        resources: [repository.repositoryArn],
      },
      targets: [
        new targets.LambdaFunction(props.noticePullRequestEventsFunction, {
          event: events.RuleTargetInput.fromObject({
            originalEvent: events.EventField.fromPath("$"),
            noticeTargets: [
              {
                "refs/heads/develop": [props.appTeamWebhookUrl],
              },
              {
                "refs/heads/main": [
                  props.appTeamWebhookUrl,
                  props.infraTeamWebhookUrl,
                ],
              },
            ],
          }),
        }),
      ],
    });
  }
}

スタック間の連携

各スタックで作成されたリソースを別スタックで参照できるように定義してあげます。また、アプリチーム/インフラチームのWebhook URLもここで渡してあげます。

実際のコードは以下の通りです。

./bin/

#!/usr/bin/env node
import "source-map-support/register";
import * as cdk from "aws-cdk-lib";
import { RoleAndApproveRuleTemplateStack } from "../lib/role-and-approve-rule-template-stack";
import { NoticePullRequestEventsFunctionStack } from "../lib/notice-pull-request-events-function-stack";
import { NoticePullRequestEventsStack } from "../lib/notice-pull-request-events-stack";

const app = new cdk.App();
const roleAndApproveRuleTemplateStack = new RoleAndApproveRuleTemplateStack(
  app,
  "RoleAndApproveRuleTemplateStack"
);
const noticePullRequestEventsFunctionStack =
  new NoticePullRequestEventsFunctionStack(
    app,
    "NoticePullRequestEventsFunctionStack"
  );
new NoticePullRequestEventsStack(app, "NoticePullRequestEventsStack", {
  appTeamWebhookUrl:
    "https://hooks.slack.com/services/xxxxxxxxxxxxxxxxxxxxxx",
  infraTeamWebhookUrl:
    "https://hooks.slack.com/services/yyyyyyyyyyyyyyyyyyyyyy",
  approvalRuleTemplate: roleAndApproveRuleTemplateStack.approvalRuleTemplate,
  noticePullRequestEventsFunction:
    noticePullRequestEventsFunctionStack.noticePullRequestEventsFunction,
});

準備ができたらnpx cdk deploy --allで全てのスタックをデプロイします。

テスト用のファイルとブランチ作成

Pull Requestを行う前に、テスト用のファイルとブランチを作成します。

CodeCommitのコンソールでAWS CDKで作成されたリポジトリを選択します。ファイルの追加 - ファイルの作成をクリックします。

テスト用のファイル作成

ファイルの中身やファイル名、作成者名などを入力して、変更のコミットをクリックします。

変更のコミット

mainブランチにファイルが追加されたことを確認します。

mainブランチにファイルが追加されたことを確認

続いてブランチを作成します。

ブランチの作成をクリックします。

ブランチの作成

ブランチ名をdevelop、ブランチ元をmainと入力・選択し、ブランチの作成をクリックして、developブランチを作成します。

ブランチの設定入力

同様の手順でfeatureブランチを作成すると、以下のようになります。

ブランチ一覧

下準備は以上で完了です。

developブランチへのPull Request作成

それでは、developブランチへのPull Requestを作成します。

featureブランチのREADME.mdに適当な文字列を追加して、変更のコミットをクリックします。

developブランチへのPull Request - 変更のコミット

featureブランチのREADME.mdを確認して編集した結果を確認して、プルリクエストの作成をクリックします。

developブランチへのPull Request作成

ターゲットをdevelop、ソースをfeatureのPull Requestを作成します。タイトルや説明、差分を確認してプルリクエストの作成をクリックします。

ターゲットをdevelop、ソースをfeatureのPull Requestを作成

アプリチームのチャンネルを確認すると、developブランチへのPull Requestが作成されたことを知らせるメッセージが届いていました。

developブランチへのPull Request作成が作成されたことを知らせるメッセージ

こちらのメッセージにはAWSマネージメントコンソールへのURLが記載されているので、そちらをクリックします。クリックすると以下のように対象のPull Requestが表示されます。

developブランチへのPull Request

developブランチへのPull Requestの承認/承認取り消し

Pull Requestの承認

次にPull Request承認時の動作確認を行います。

承認可能なIAMロールにスイッチロールをすると、承認ボタンが表示されるので、そちらをクリックします。

developブランチへのPull Requestの承認

アプリチームのチャンネルを確認すると、developブランチへのPull Requestが承認されたことを知らせるメッセージが届いていました。

developブランチへのPull Requestが承認されたことを知らせるメッセージ

Pull Requestの承認取り消し

Pull Requestの承認が取り消された場合の動作確認も行います。

承認をすると、承認を取り消しボタンが表示されるので、そちらをクリックします。

developブランチへのPull Requestの承認取り消し

アプリチームのチャンネルを確認すると、developブランチへのPull Requestの承認が取り消されたことを知らせるメッセージが届いていました。

developブランチへのPull Requestの承認が取り消されたことを知らせるメッセージが

developブランチへのPull Requestへのコメント

ファイルに関するコメント追加

ファイルに関するコメントを追加した場合の動作確認を行います。

変更タブからファイルに関するコメントをクリックすると、テキストエリアが表示されます。テキストエリアに適当にコメントを入力して、保存をクリックします。

ファイルに関するコメント追加

アプリチームのチャンネルを確認すると、README.mdへのコメント追加を知らせるメッセージが届いていました。

README.mdへのコメント追加を知らせるメッセージ

AWS Management Console URLのURLをクリックすると、対象Pull Requestのアクティビティ履歴を表示してくれます。

対象Pull Requestのアクティビティ履歴を表示

ファイルに関するコメント編集

ファイルに関するコメントを編集した場合の動作確認も行います。

先程のコメントの編集ボタンをクリックすると、テキストエリアが表示されます。テキストエリアに適当にコメントを入力して、保存をクリックします。

ファイルに関するコメント編集

アプリチームのチャンネルを確認すると、README.mdへのコメントが編集されたことを知らせるメッセージが届いていました。

README.mdへのコメントが編集されたことを知らせるメッセージ

AWS Management Console URLのURLをクリックすると、対象Pull Requestのアクティビティ履歴を表示してくれます。Markdown形式なので、更新部分が1行で表示されていますね。

ファイルへのコメント編集後の対象Pull Requestのアクティビティ履歴を表示

変更に関するコメント追加

次に変更に関するコメントを追加した場合の動作確認を行います。

変更タブの変更に関するコメントのテキストエリアに適当にコメントを入力して、保存をクリックします。

変更に関するコメント追加

アプリチームのチャンネルを確認すると、変更に関するコメントが追加されたことを知らせるメッセージが届いていました。

変更に関するコメント追加を知らせるメッセージ

変更に関するコメント編集

変更に関するコメントを編集した場合の動作確認も行います。

先程のコメントの編集ボタンをクリックすると、テキストエリアが表示されます。テキストエリアに適当にコメントを入力して、保存をクリックします。

変更に関するコメント編集

アプリチームのチャンネルを確認すると、変更に関するコメントが編集されたことを知らせるメッセージが届いていました。

変更に関するコメントが編集されたことを知らせるメッセージ

AWS Management Console URLのURLをクリックすると、対象Pull Requestのアクティビティ履歴を表示してくれます。Markdownを解釈して表示されていることが分かります。

変更へのコメント編集後の対象Pull Requestのアクティビティ履歴を表示

developブランチへのPull Requestのマージ

マージした場合の動作確認も行います。

Pull Requestを承認すると、マージボタンが表示されるので、そちらをクリックします。

developブランチへのPull Requestのマージ

マージ方法や作成者名、メールアドレスを選択・入力して、プルリクエストのマージをクリックします。

プルリクエストのマージ

Pull Requestがマージされたことを確認します。

Pull Requestのマージ確認

アプリチームのチャンネルを確認すると、Pull Requestがマージされたことを知らせるメッセージが届いていました。

Pull Requestがマージされたことを知らせるメッセージ

mainブランチへのPull Request作成

最後にmainブランチにPull Requestを作成して、インフラチームのチャンネルにも通知されるかどうかを確認します。

ターゲットをmain、ソースをdevelopのPull Requestを作成します。タイトルや説明、差分を確認してプルリクエストの作成をクリックします。

mainブランチへのPull Request作成

すると、インフラチームとアプリチームのチャンネルにメッセージが届いていることが確認できました。

インフラチームとアプリチームのチャンネルにメッセージが届いていることを確認

メッセージを確認すると、どちらもmainブランチへのPull Requestが作成されたことを知らせるメッセージでした。

mainブランチへのPull Requestが作成されたことを知らせるメッセージ

対応していなければLambda関数にやらせればいいじゃないか

EventBridgeルールとLambda関数を使ってCodeCommitのPull Request関連のイベントをSlackに通知してみました。

AWS Catbotの動作が未対応だったので、Lambda関数にやってもらいました。今回はSlackチャンネルに通知しましたが、同様の仕組みでTeamsのチャンネルにも通知できると考えています。

この記事が誰かの助けになれば幸いです。

以上、AWS事業本部 コンサルティング部の のんピ(@non____97)でした!