📑

NotionのGitHubプルリクエスト連携機能をGitHub Actionsで代用してみた

に公開

こんにちは!CastingONEの大沼です。

始めに

NotionのプロパティにはGitHubプルリクエストを設定することができ、そこにGitHubプルリクエストのURLを貼ったり、逆にGitHubプルリクエストのタイトルにTSK-123などNotionのIDプロパティの文字列をつけることでNotionに紐づけることができます。更にプルリクエストのレビューを出したりクローズしたら自動で紐づいたNotionのステータスを切り替えることができ、Notionでのタスク管理がやりやすいです。

しかしながらこの機能は2025年8月13日からビジネスプラン以上でしか使えなくなります。詳細の説明でこちらのリンクが紹介されていましたが、どうやらAIを使った検索にGitHubなどの外部サービスも対象にする関係でビジネスプラン以上を要求するようになるようです🤔
https://www.notion.com/ja/blog/notion-ai-for-work

これを機にNotion AIを使っていきたい方はこのままビジネスプランに上げれば問題ないですが、プラスプランと比べて倍くらいの値段になってしまうため中々踏み切れない企業もいるかと思います。実際弊社も同じ状況で、コストに見合う利益を得られるか疑問でビジネスプランには上げない方針になりました。

このままだとNotionのステータス更新を毎回手で変えていく運用になってしまいそれは体験悪いと思い何か妙案がないか模索していましたが、GitHub ActionsでNotion API叩くことで今までのようなGitHubプルリクエストURLの紐付けとステータスの自動更新することができたので、その方法について紹介したいと思います。

余談: タスク管理をGitHub Projectsで運用する案

話は変わりますが、そもそもタスク管理をNotionからGitHub Projectsに移行する考えもあると思います。最近のGitHub Projectsは結構機能が充実してきており、Zenhubなど別なサービスを使わなくても十分に運用できるレベルになっているようです。

https://zenn.dev/cureapp/articles/5f9338227030ce

しかし弊社はすでに仕様についてはNotionでまとめていてNotionタスクにその情報をリンクすることもあり、迂闊に切り離すのはどうなのかと思い直近の移行は断念しました。

GitHub Actionsを使ったNotionの連携方法

GitHubプルリクエストをNotionと連携するためのアクションは以下のリポジトリで実装しましたので、このセクションではこのアクションを使った連携方法について説明します。このアクション自体の実装内容については次のセクションで説明したいと思います。

https://github.com/TakanoriOnuma/notion-task-connector-with-github-pr

このアクションを使ってgifアニメのようにNotionのGitHubプルリクエスト連携機能のような挙動を再現することができます。

タイトル変更時の追従と複数のID指定もできます。Notionの機能では1つ1つバラバラにコメントされていたのに対し、GitHub Actionsの方ではリンク一覧を1つにまとめてコメントすることが可能です。

更に複数のGitHubプルリクエストが紐づくケースもサポートされており、既存のテキストリンクの下に追記されます。

Notionのインテグレーションを作成

まずはNotionのインテグレーションを作成します。Notionのインテグレーションから「新しいインテグレーション」をクリックします。

そこでインテグレーション名の入力と関連ワークスペースを選択して保存します。

保存後の設定画面で、ntn_から始まるインテグレーションシークレットをコピーしておきます。GitHub Actionsで使用するので、Actionsのシークレットに保存しておきます(この記事ではNOTION_TOKENという名前で保存したものとして進めます)。

連携先のNotionテーブルにインテグレーションを接続

連携先のNotionテーブルにアクセスします。この時、スクショのURL上に赤線で引かれている部分はNotionのデータベースIDになっており、これも必要になるのでコピーしてGitHub Actionsのシークレットに保存します(この記事ではDATABASE_IDという名前で保存したものとして進めます)。

このページの右上のメニューから「接続」を選択して、先ほど作ったインテグレーション名を選択して接続します。

GitHub Actionの設定

後はGitHub ActionsからNotionと連携します。今回お見せするサンプルは以下のことをやります。env.****という環境変数名で設定した値に変わるという意味です。

  • プルリクエストの先頭に書かれた[ ]の中のNotionタスクのIDプロパティを読み取る([TSK-123, TSK-456]など複数指定可)
    • このNotionタスクに対してアクションのトリガーとなったプルリクエストのリンクをテキストプロパティに更新
    • このNotionタスクに対して以下の条件の時にステータスを更新
      • プルリクエストをオープン or 再オープンした時 → env.NOTION_STATUS_PROPERTY_VALUE_IN_PROGRESS
      • レビューをつけた時 → env.NOTION_STATUS_PROPERTY_VALUE_REVIEW
      • プルリクエストがマージした時 → env.NOTION_STATUS_PROPERTY_VALUE_COMPLETED

一気に色々やるのでコードが長くなってしまいますが、以下のようなアクションになります。使用する際はenv部分を環境に応じて差し替えてください。なお、サンプルで使用する変更対象のNotionプロパティはスクショのような設定になっており、ここに表示されているラベルをenvに設定しています。

ステータス GitHubプルリクエスト
GitHub PR情報をNotionタスクに紐づけるアクション
# .github/workflows/connect-notion-task.yml
name: Connect Notion Task with GitHub PR

on:
  pull_request:
    types:
      - opened
      - reopened
      - edited
      - review_requested
      - closed

# 環境によって適切な値を入れる
env:
  NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} # ntn_から始まるNotionのトークン
  DATABASE_ID: ${{ secrets.DATABASE_ID }} # NotionページURLの先頭に付くID
  NOTION_STATUS_PROPERTY_NAME: "ステータス"
  NOTION_STATUS_PROPERTY_VALUE_IN_PROGRESS: "進行中"
  NOTION_STATUS_PROPERTY_VALUE_REVIEW: "レビュー中"
  NOTION_STATUS_PROPERTY_VALUE_COMPLETED: "完了"
  NOTION_GITHUB_PR_PROPERTY_NAME: "GitHub PR(text)"

jobs:
  connect-notion-task-with-github-pr:
    runs-on: ubuntu-22.04
    permissions:
      contents: read
      pull-requests: write
    steps:
      - uses: actions/checkout@v4

      - name: 変更前のNotion ID Propertyを抽出
        if: github.event.changes.title != null
        id: extract_notion_id_property_before
        run: |
          TITLE="${{ github.event.changes.title.from }}"
          if [[ "$TITLE" =~ ^\[(.+?)\] ]]; then
            NOTION_ID_PROPERTY_BEFORE="${BASH_REMATCH[1]}"
            echo "抽出された変更前のNotion ID Property: $NOTION_ID_PROPERTY_BEFORE"
            echo "VALUE=$NOTION_ID_PROPERTY_BEFORE" >> $GITHUB_OUTPUT
          fi

      - name: Notion ID Propertyを抽出
        id: extract_notion_id_property
        run: |
          TITLE="${{ github.event.pull_request.title }}"
          if [[ "$TITLE" =~ ^\[(.+?)\] ]]; then
            NOTION_ID_PROPERTY="${BASH_REMATCH[1]}"
            echo "抽出されたNotion ID Property: $NOTION_ID_PROPERTY"
            echo "VALUE=$NOTION_ID_PROPERTY" >> $GITHUB_OUTPUT
          fi

      - name: Notionステータスの更新値を設定
        if: steps.extract_notion_id_property.outputs.VALUE != '' || steps.extract_notion_id_property_before.outputs.VALUE != ''
        id: set_notion_status
        run: |
          case "${{ github.event.action }}" in
            opened|reopened)
              STATUS="${{ env.NOTION_STATUS_PROPERTY_VALUE_IN_PROGRESS }}"
              ;;
            review_requested)
              STATUS="${{ env.NOTION_STATUS_PROPERTY_VALUE_REVIEW }}"
              ;;
            closed)
              if [[ "${{ github.event.pull_request.merged }}" == 'true' ]]; then
                STATUS="${{ env.NOTION_STATUS_PROPERTY_VALUE_COMPLETED }}"
              fi
              ;;
          esac
          if [[ -n "$STATUS" ]]; then
            echo "更新先のNotionステータス: $STATUS"
            echo "VALUE=$STATUS" >> $GITHUB_OUTPUT
            echo "PROPERTY_NAME=${{ env.NOTION_STATUS_PROPERTY_NAME }}" >> $GITHUB_OUTPUT
          else
            echo "ステータス更新はありません"
          fi

      - name: Change Notion Task
        if: steps.extract_notion_id_property.outputs.VALUE != '' || steps.extract_notion_id_property_before.outputs.VALUE != ''
        uses: TakanoriOnuma/notion-task-connector-with-github-pr@v0.0.4
        with:
          beforeNotionIdProperty: ${{ steps.extract_notion_id_property_before.outputs.VALUE }}
          notionIdProperty: ${{ steps.extract_notion_id_property.outputs.VALUE }}
          statusPropertyName: ${{ steps.set_notion_status.outputs.PROPERTY_NAME }}
          statusPropertyValue: ${{ steps.set_notion_status.outputs.VALUE }}
          githubPrPropertyName: ${{ env.NOTION_GITHUB_PR_PROPERTY_NAME }}
        env:
          NOTION_TOKEN: ${{ env.NOTION_TOKEN }}
          DATABASE_ID: ${{ env.DATABASE_ID }}

      - name: Comment Connected Tasks
        if: steps.extract_notion_id_property.outputs.VALUE != '' || steps.extract_notion_id_property_before.outputs.VALUE != ''
        uses: thollander/actions-comment-pull-request@v3
        with:
          file-path: ./connected-tasks.md
          comment-tag: CONNECTED_NOTION_TASKS

このアクションの処理をざっくり説明すると以下のような流れになります。

  1. Notion IDプロパティを取得
    プルリクエストのタイトルの先頭に書かれている[ ]の中身を正規表現で抽出
    (タイトル変更時の場合は変更前の場合も抽出しておく)
    ※TSK-123, TSK-456など複数指定の場合はアクション側でパースするためこの文字列のまま渡す
  2. Notionステータスの更新値を設定
    プルリクエストオープン時、レビュワー設定時、マージ時などのイベントトリガーに応じた更新したいNotionステータス値をマッピング
  3. 1と2の情報を自作アクションに渡して、Notionのプロパティを更新する
  4. Notionとの連携した情報を./connected-tasks.mdに出力しているため、それをGitHubプルリクエスト上にコメント

今回の例ではプルリクエストのタイトルの先頭に[ ]で囲うことを前提にした作りですが、Notionのプルリクエスト連携機能のようにブラケットが不要でも連携できたり、ブランチ名でも連携できたりなども実装次第ではできると思います。この辺はユースケースに応じてカスタマイズしてください。

GitHubプルリクエスト情報をNotionのタスクに登録するGitHub Actionsの実装

ここからは自作したActionの中身について説明します。とは言えNotionとの連携はCLIから実行することも可能なので、できるだけ処理は別メソッドに切り出しておき、パラメータを渡すところだけGitHub Actionsを経由するという設計にしました。なので先にNotionプロパティを更新する中身の実装を説明し、その後にGitHub Actionsでの呼び出し方、publicで使えるようにするやり方を説明します。
なお、CLIから実行する内容については割愛させてください。リポジトリの方では最初の動作検証で使っていたコードは残っているので興味がある方はそのコードを参照していただければと思います。

@notionhq/clientを使ったNotionプロパティの更新メソッドを実装

今回Node.jsでNotionと連携するため、以下のパッケージを使って実装しました。

https://www.npmjs.com/package/@notionhq/client

NotionのIDプロパティを使ってページを取得する

まずはNotionのIDプロパティを指定して該当するページを取得できるようにします。仕様を見て驚いたのはTSK-123などprefixがあるのにも関わらず検索APIではむしろ邪魔で数字の部分だけが大事なようでした。とは言え今後prefixの情報が必要になるかもしれないのでprefixとnumberを分けた型を用意して、それを引数にして該当のNotionページを取得するメソッドを書きました。なお、この後更新処理も書く際にNotionClientを引き回すよりクラスでまとまっていた方が扱いやすいと思ったのでクラスで実装しています。

NotionのユニークIDを使ってページを取得する
import { Client } from "@notionhq/client";

/** NotionのユニークIDプロパティ情報 */
type NotionUniqueId = {
  /** プレフィックス */
  prefix: string;
  /** ID */
  number: number;
};

export class NotionManager {
  private notion: Client;

  constructor() {
    this.notion = new Client({
      auth: process.env.NOTION_TOKEN,
    });
  }

  /**
   * NotionのユニークIDを使ってページを取得する
   * @param notionUniqueId - NotionのユニークID
   */
  async fetchNotionPageByUniqueId(notionUniqueId: NotionUniqueId) {
    const response = await this.notion.databases.query({
      database_id: process.env.DATABASE_ID || "",
      filter: {
        property: "ID",
        type: "unique_id",
        unique_id: {
          equals: notionUniqueId.number,
        },
      },
    });
    const targetPage = response.results[0];
    if (targetPage == null) {
      throw new Error(
        "該当するページが見つかりませんでした: " +
          JSON.stringify(notionUniqueId)
      );
    }
    if (!("properties" in targetPage) || !("parent" in targetPage)) {
      throw new Error(
        "取得したページにプロパティがありません: " + targetPage.id
      );
    }
    if ("title" in targetPage) {
      throw new Error(
        "取得したページにタイトルが含まれています(これはデータベースオブジェクトです): " +
          targetPage.id
      );
    }
    return targetPage;
  }
}

TSK-123から{ prefix: 'TSK', number: 123 }のようにデータを分けておく必要があるため、その処理を行うメソッドを用意します。またGitHub ActionsからはTSK-123, TSK-456のように複数のタスクIDをカンマ区切りで送れる想定なので、その辺のパース処理も合わせて行います。

NotionのIDプロパティをprefixとnumberに分けたデータにパースする
/**
 * NotionのユニークID文字列を解析して、NotionUniqueIdの配列に変換する
 * @param notionIdProperty - NotionのユニークIDプロパティ値。複数指定する場合はカンマ区切りで指定する。例: "TSK-123, TSK-456"
 */
export const parseNotionUniqueIds = (
  notionIdProperty: string
): NotionUniqueId[] => {
  const notionUniqueIds: NotionUniqueId[] = notionIdProperty
    .split(",")
    .map((idStr) => idStr.trim())
    .map((idStr): NotionUniqueId => {
      const [prefix, numberStr] = idStr.split("-");
      if (prefix == null || numberStr == null) {
        throw new Error(
          "Notion IDの形式が正しくありません。例: 'TSK-123' のように入力してください。" +
            idStr
        );
      }
      if (/^\d+/.test(numberStr) === false) {
        throw new Error(
          "Notion IDの番号部分は数字でなければなりません。例: 'TSK-123' のように入力してください。" +
            idStr
        );
      }
      return {
        prefix,
        number: parseInt(numberStr, 10),
      };
    });

  return notionUniqueIds;
};

GitHubプルリクエスト情報をリッチテキストプロパティから取得する

GitHubプルリクエスト情報をNotionのプロパティに保存する際のプロパティタイプは、リッチテキスト形式を採用しました。URLタイプもありましたが、こちらは1つしか登録できず、しかもそのURLにテキストを指定することができない問題もあったので却下しました。リッチテキストは複数行入力することができ、テキストリンクにすることもできるため非常に柔軟性が高いです。ただその分実装が複雑になってしまうのでラッパーを用意してコードの流れを読みやすい設計にしました。
まずはリッチテキストプロパティからGitHubプルリクエストのURLとテキストを取得します。こちらも取得したデータを追加したり削除したり、最終的にリッチテキストに変換する処理を書く必要がありますがどれもGitHubプルリクエスト情報を使い回していくのでクラスでまとめる方針でいきます。

GitHubプルリクエスト情報をリッチテキストプロパティから取得する
import { PageObjectResponse } from "@notionhq/client";

/** GitHubのPR情報 */
export type GitHubPr = {
  title: string;
  url: string;
};

/**
 * GitHub PRのリンクテキストを取得する関数
 * @param property - Notionのページオブジェクトのプロパティ
 */
const getGitHubPrs = (property: PageObjectResponse["properties"][string]) => {
  if (property.type !== "rich_text") {
    throw new Error("'rich_text'タイプのプロパティではありません");
  }
  const githubRegExp = new RegExp("^https://github.com");
  const githubPrs: GitHubPr[] = [];
  property.rich_text.forEach((text) => {
    if (text.type !== "text") {
      return;
    }
    if (text.text.link == null) {
      return;
    }
    if (!githubRegExp.test(text.text.link.url)) {
      return;
    }
    githubPrs.push({
      title: text.text.content.trim(),
      url: text.text.link.url,
    });
  });
  return githubPrs;
};

export class GitHubPrManager {
  /** GitHubのPR情報リスト */
  public githubPrs: GitHubPr[];

  constructor(property: PageObjectResponse["properties"][string]) {
    this.githubPrs = getGitHubPrs(property);
  }
}

GitHubプルリクエスト情報を追加・削除、リッチテキストに変換するメソッドを実装

続いて取得したGitHubプルリクエスト情報に追加・削除できるようにします。

GitHubプルリクエスト情報の追加・削除
 // 省略

 export class GitHubPrManager {
   /** GitHubのPR情報リスト */
   public githubPrs: GitHubPr[];

   constructor(property: PageObjectResponse["properties"][string]) {
     this.githubPrs = getGitHubPrs(property);
   }

+  /**
+   * GitHubのPR情報を追加する
+   * @param githubPr - GitHubのPR情報
+   */
+  addGitHubPr(githubPr: GitHubPr) {
+    const index = this.githubPrs.findIndex((pr) => pr.url === githubPr.url);
+    // 既に同じURLのPRが存在する場合は更新
+    if (index !== -1) {
+      this.githubPrs[index] = githubPr;
+      return;
+    }
+    // 新しいPRの場合は追加
+    this.githubPrs.push(githubPr);
+  }

+  /**
+   * GitHubのPR情報を削除する
+   * @param githubPr - GitHubのPR情報
+   */
+  removeGitHubPr(githubPr: GitHubPr) {
+    const index = this.githubPrs.findIndex((pr) => pr.url === githubPr.url);
+    if (index !== -1) {
+      this.githubPrs.splice(index, 1);
+    } else {
+      // 必要に応じてエラーハンドリングを行う
+      // throw new Error("指定されたGitHub PRが見つかりません: " + githubPr.url);
+    }
+  }
 }

最後のその結果を再度保存できるようにリッチテキストに変換するメソッドを用意します。

GitHubプルリクエスト情報をリッチテキストに変換する
-import { PageObjectResponse } from "@notionhq/client";
+import { PageObjectResponse, UpdatePageParameters } from "@notionhq/client";

+/** Notionプロパティ(リッチテキスト) */
+export type RichTextProperty = Extract<
+  Required<UpdatePageParameters>["properties"][string],
+  { type?: "rich_text" }
+>;

 export class GitHubPrManager {
   // 省略

+  /**
+   * Notionのプロパティに保存するためのGitHub PRのリッチテキストを作成する
+   */
+  createGitHubPrsRichText() {
+    const richTexts: RichTextProperty["rich_text"] = this.githubPrs.map(
+      (pr) => ({
+        type: "text",
+        text: {
+          content: pr.title,
+          link: {
+            url: pr.url,
+          },
+        },
+      })
+    );
+    return richTexts.flatMap(
+      (richText, index): RichTextProperty["rich_text"] => {
+        if (index === richTexts.length - 1) {
+          return [richText]; // 最後の要素は改行なし
+        }
+        return [richText, { type: "text", text: { content: "\n" } }];
+      }
+    );
+  }
 }

Notionプロパティを更新するメソッドを用意

続いて変換したリッチテキストをNotionプロパティに更新できるようにメソッドを用意します。プロパティの更新は一括でできるのでステータスの更新処理を合わせて実装します。

Notionプロパティを更新する
 import { Client } from "@notionhq/client";
+import { NotionUniqueId } from "./parseNotionUniqueIds";
+import { RichTextProperty } from "./GitHubPrManager";

+/** Notionプロパティ情報 */
+export type NotionProperty<T> = {
+  /** プロパティ名 */
+  name: string;
+  /** プロパティの値 */
+  value: T;
+};

 export class NotionManager {
   private notion: Client;

   constructor() {
     this.notion = new Client({
       auth: process.env.NOTION_TOKEN,
     });
   }

   // fetchNotionPageByUniqueIdの実装は省略

+  /**
+   * Notionページのプロパティを更新する
+   * @param notionPageId - NotionのページID
+   * @param propertySet - 更新するプロパティ群
+   */
+  async updateNotionProperty(
+    notionPageId: string,
+    propertySet: {
+      /** 変更するステータスのNotionプロパティ。変更しない場合は未指定。 */
+      statusProperty?: NotionProperty<string>;
+      /** 変更するGitHubのPR情報のリッチテキストNotionプロパティ */
+      githubPrRichTextProperty: NotionProperty<RichTextProperty["rich_text"]>;
+    }
+  ) {
+    const { statusProperty, githubPrRichTextProperty } = propertySet;
+
+    const result = await this.notion.pages.update({
+      page_id: notionPageId,
+      properties: {
+        ...(statusProperty != null
+          ? {
+              [statusProperty.name]: {
+                type: "status",
+                status: {
+                  name: statusProperty.value,
+                },
+              },
+            }
+          : {}),
+        [githubPrRichTextProperty.name]: {
+          type: "rich_text",
+          rich_text: githubPrRichTextProperty.value,
+        },
+      },
+    });
+    return result;
+  }
 }

対象のNotionにGitHubプルリクエスト情報を追加して更新する

今まで作ったコードを使って、指定したプロパティIDに該当するNotionにGitHubプルリクエスト情報を追記する処理を書きます。ステータスの更新もオプショナルで用意しておきます。

対象のNotionにGitHubプルリクエスト情報を追加して更新する
import { GitHubPr, GitHubPrManager } from "./utils/GitHubPrManager";
import { parseNotionUniqueIds } from "./utils/parseNotionUniqueIds";
import { NotionManager, NotionProperty } from "./utils/NotionManager";

type Args = {
  /**
   * 変更対象のNotionのユニークIDプロパティ値。複数指定する場合は神間区切りで指定する。
   * @example "TSK-123, TSK-456"
   */
  notionIdProperty: string;
  /** 変更するステータスのNotionプロパティ。変更しない場合は未指定。 */
  statusProperty?: NotionProperty<string>;
  /** 変更するGitHubのPR情報のNotionプロパティ */
  githubPrProperty: NotionProperty<GitHubPr>;
};

/**
 * Notionのプロパティを変更する関数
 */
export const changeNotionProperty = async ({
  notionIdProperty,
  statusProperty,
  githubPrProperty,
}: Args) => {
  const notionManager = new NotionManager();
  const notionUniqueIds = parseNotionUniqueIds(notionIdProperty);

  // 対象のNotionページを更新する
  const results = await Promise.all(
    notionUniqueIds.map(async (notionUniqueId) => {
      const targetPage = await notionManager.fetchNotionPageByUniqueId(
        notionUniqueId
      );

      const githubPrManager = new GitHubPrManager(
        targetPage.properties[githubPrProperty.name]
      );
      githubPrManager.addGitHubPr(githubPrProperty.value);

      return await notionManager.updateNotionProperty(targetPage.id, {
        statusProperty,
        githubPrRichTextProperty: {
          name: githubPrProperty.name,
          value: githubPrManager.createGitHubPrsRichText(),
        },
      });
    })
  );
};

変更によって連携解除されるNotionタスクからGitHubプルリクエスト情報を取り除いて更新する

追加はできましたが、逆に連携解除される場合も実装します。これはそもそも変更前はIDが指定されていて、今回から消えていたことを判別する必要があるのでbeforeNotionIdPropertyを増やします。これによってnotionIdPropertyは必須ではなくbeforeNotionIdPropertyのどちらかがあれば良いという条件に変わるため、型をoptionalにし、実装側でチェックを入れます。

変更によって連携解除されるNotionタスクからGitHubプルリクエスト情報を取り除いて更新する
 // 省略

 type Args = {
   /**
    * 変更対象のNotionのユニークIDプロパティ値。複数指定する場合は神間区切りで指定する。
    * @example "TSK-123, TSK-456"
    */
-  notionIdProperty?: string;
+  notionIdProperty?: string;
+  /**
+   * 以前指定していたNotionのユニークIDプロパティ値。notionIdPropertyとの差分を見て、消えたものについてはunlinkする。
+   * @example "TSK-123, TSK-456"
+   */
+  beforeNotionIdProperty?: string;
   /** 変更するステータスのNotionプロパティ。変更しない場合は未指定。 */
   statusProperty?: NotionProperty<string>;
   /** 変更するGitHubのPR情報のNotionプロパティ */
   githubPrProperty: NotionProperty<GitHubPr>;
 };

 /**
  * Notionのプロパティを変更する関数
  */
 export const changeNotionProperty = async ({
   notionIdProperty,
+  beforeNotionIdProperty,
   statusProperty,
   githubPrProperty,
 }: Args) => {
   const notionManager = new NotionManager();
-  const notionUniqueIds = parseNotionUniqueIds(notionIdProperty);
+  const notionUniqueIds = notionIdProperty
+    ? parseNotionUniqueIds(notionIdProperty)
+    : [];
+  const beforeNotionUniqueIds = beforeNotionIdProperty
+    ? parseNotionUniqueIds(beforeNotionIdProperty)
+    : [];

+  if (notionUniqueIds.length <= 0 && beforeNotionUniqueIds.length <= 0) {
+    throw new Error(
+      "NotionのユニークIDプロパティが指定されていません。" +
+        "notionIdPropertyまたはbeforeNotionIdPropertyのいずれかを指定してください。"
+    );
+  }

+  // 除外されたNotionページはunlinkする
+  const removedNotionUniqueIds = beforeNotionUniqueIds.filter(
+    (beforeNotionUniqueId) => {
+      return !notionUniqueIds.some((notionUniqueId) => {
+        return (
+          notionUniqueId.prefix === beforeNotionUniqueId.prefix &&
+          notionUniqueId.number === beforeNotionUniqueId.number
+        );
+      });
+    }
+  );
+  await Promise.all(
+    removedNotionUniqueIds.map(async (notionUniqueId) => {
+      const targetPage = await notionManager.fetchNotionPageByUniqueId(
+        notionUniqueId
+      );
+
+      const githubPrManager = new GitHubPrManager(
+        targetPage.properties[githubPrProperty.name]
+      );
+      // 自分のGitHub PR情報を取り除く
+      githubPrManager.removeGitHubPr(githubPrProperty.value);
+
+      return await notionManager.updateNotionProperty(targetPage.id, {
+        statusProperty: statusProperty,
+        githubPrRichTextProperty: {
+          name: githubPrProperty.name,
+          value: githubPrManager.createGitHubPrsRichText(),
+        },
+      });
+    })
+  );

   // 対象のNotionページを更新する
   // 省略
 };

紐付けたNotionタスク一覧をファイルに出力する

最後に紐づいたNotionタスク一覧をファイルに出力します。理想はNotionのリンクとタイトルのオブジェクト配列を返すことでしたが、GitHub Actionsのoutputsは文字列のみしか受け付けておらず、JSON.stringify送っても文字数上限が気になったり、パース後の処理をアクション上でやるのは大変だと思い、ファイル出力にしました。

紐付けたNotionタスク一覧をファイルに出力する
+import { promises as fsPromises } from "fs";
+import { PageObjectResponse } from "@notionhq/client";

 // 省略

 /**
  * Notionのプロパティを変更する関数
  */
 export const changeNotionProperty = async ({
   notionIdProperty,
   beforeNotionIdProperty,
   statusProperty,
   githubPrProperty,
 }: Args) => {
   // 省略

   // 対象のNotionページを更新する
   const results = await Promise.all(
     // 省略
   );

+  // 結果をMarkdown形式でファイルに保存
+  const getPageTitle = (result: PageObjectResponse) => {
+    const titleProperty = Object.values(result.properties).find(
+      (prop) => prop.type === "title"
+    );
+    if (titleProperty == null) {
+      throw new Error(
+        "ページのタイトルプロパティが見つかりませんでした: " + result.id
+      );
+    }
+    const title = titleProperty.title.map((text) => text.plain_text).join("");
+    return title;
+  };

+  const text = [
+    "Notionの関連タスク一覧です",
+    ...results.map((result) => {
+      if (!("properties" in result)) {
+        throw new Error("ページのプロパティが見つかりません: " + result.id);
+      }
+      return `- [${getPageTitle(result)}](${result.url})`;
+    }),
+  ].join("\n");
+  await fsPromises.writeFile("./connected-tasks.md", text, "utf8");
 };

GitHub ActionsからNotionプロパティ更新メソッドを呼び出す

前のセクションで作ったchangeNotionPropertyというメソッドがCLIでもGitHub Actionsでも呼び出せる汎用的なものなので、これを自作のアクションで呼び出すようにします。中身はこのメソッドで完結しているので、パラメータの受け渡し部分のみになります。

アクションの定義

まずはインターフェースを決めるため、アクションを定義します。ルートにaction.ymlを置くとリポジトリ指定でアクションとして使えるようになるのでそこに配置します。ただしnodeの実行ファイルはビルド済みのものでないと動かないため、ビルド先を仮で用意してそこのファイルパスを指定します。

action.yml
name: Change Notion Task Property
description: Notionタスクのプロパティを変更するアクション

inputs:
  notionIdProperty:
    description: |
      変更対象のNotionのユニークIDプロパティ値。複数指定する場合はカンマ区切りで指定する。
      例: "TSK-123, TSK-456"
  beforeNotionIdProperty:
    description: |
      以前指定していたNotionのユニークIDプロパティ値。notionIdPropertyとの差分を見て、消えたものについてはunlinkする。
      例: "TSK-123, TSK-456"
  # Notion Statusのプロパティ
  statusPropertyName:
    description: 変更するステータスのNotionプロパティ名
  statusPropertyValue:
    description: 変更後のステータスのNotionプロパティ値
  # Notion GitHub PRのプロパティ
  githubPrPropertyName:
    description: GitHub PRのプロパティ名
    required: true

runs:
  using: node20
  main: actions/dist/index.js

アクションの中身を実装

続いて中身を実装します。とは言え、パラメータを受け渡すだけなので型エラーにならないようにタイプガードを入れつつ適切なパラメータを渡しているだけになります。

import * as core from "@actions/core";
import { context } from "@actions/github";
import { changeNotionProperty } from "../src/changeNotionProperty";

export async function run(): Promise<void> {
  const prPayload = context.payload.pull_request;
  if (prPayload == null) {
    core.setFailed("Pull request payload is not available.");
    return;
  }

  const notionIdProperty = core.getInput("notionIdProperty");
  const beforeNotionIdProperty = core.getInput("beforeNotionIdProperty");
  if (!notionIdProperty && !beforeNotionIdProperty) {
    core.setFailed(
      "'notionIdProperty'か'beforeNotionIdProperty'のいずれかを指定してください。"
    );
    return;
  }

  const statusPropertyName = core.getInput("statusPropertyName") || undefined;
  const statusPropertyValue = core.getInput("statusPropertyValue") || undefined;
  const githubPrPropertyName = core.getInput("githubPrPropertyName", {
    required: true,
  });

  await changeNotionProperty({
    notionIdProperty,
    beforeNotionIdProperty,
    statusProperty:
      statusPropertyName && statusPropertyValue
        ? {
            name: statusPropertyName,
            value: statusPropertyValue,
          }
        : undefined,
    githubPrProperty: {
      name: githubPrPropertyName,
      value: {
        title: prPayload.title,
        url: prPayload.html_url ?? prPayload.url,
      },
    },
  });
}

アクションのビルド

このアクションをビルドして実行できる状態にします。@vercel/nccでビルドするのが一番楽なので、今回はそれを使ってビルドしました。

package.json
{
  "scripts": {
    "build": "ncc build actions/index.ts -o actions/dist",
  }
}

開発中の動作確認

ビルドしたものをGitHub Actionsとして実行して動作確認したい場合、以下のようにビルド後に自分のパスを指定することで実行することができます。

開発中のアクションの動作確認
 name: Connect Notion Task with GitHub PR

 on:
   pull_request:
     types:
       - opened
       - reopened
       - edited
       - review_requested
       - closed

 env:
   NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
   DATABASE_ID: ${{ secrets.DATABASE_ID }}
   NOTION_STATUS_PROPERTY_NAME: "ステータス"
   NOTION_STATUS_PROPERTY_VALUE_IN_PROGRESS: "進行中"
   NOTION_STATUS_PROPERTY_VALUE_REVIEW: "レビュー中"
   NOTION_STATUS_PROPERTY_VALUE_COMPLETED: "完了"
   NOTION_GITHUB_PR_PROPERTY_NAME: "GitHub PR(text)"

 jobs:
   connect-notion-task-with-github-pr:
     runs-on: ubuntu-22.04
     permissions:
       contents: read
       pull-requests: write
     steps:
       # Notion IDプロパティの抽出やNotionステータス更新値の設定ステップは省略

+      # アクションビルド用のステップ
+      - name: Set up Node.js
+        if: steps.extract_notion_id_property.outputs.VALUE != '' || steps.extract_notion_id_property_before.outputs.VALUE != ''
+        uses: actions/setup-node@v4
+        with:
+          node-version-file: package.json
+          cache: "npm"
+      - name: Install
+        if: steps.extract_notion_id_property.outputs.VALUE != '' || steps.extract_notion_id_property_before.outputs.VALUE != ''
+        run: npm install
+      - name: Build Action
+        if: steps.extract_notion_id_property.outputs.VALUE != '' || steps.extract_notion_id_property_before.outputs.VALUE != ''
+        run: npm run build

       - name: Change Notion Task
         if: steps.extract_notion_id_property.outputs.VALUE != '' || steps.extract_notion_id_property_before.outputs.VALUE != ''
+        # カレントディレクトリにあるaction.ymlが実行される
+        uses: ./
-        uses: TakanoriOnuma/notion-task-connector-with-github-pr@v0.0.4
         with:
           beforeNotionIdProperty: ${{ steps.extract_notion_id_property_before.outputs.VALUE }}
           notionIdProperty: ${{ steps.extract_notion_id_property.outputs.VALUE }}
           statusPropertyName: ${{ steps.set_notion_status.outputs.PROPERTY_NAME }}
           statusPropertyValue: ${{ steps.set_notion_status.outputs.VALUE }}
           githubPrPropertyName: ${{ env.NOTION_GITHUB_PR_PROPERTY_NAME }}
         env:
           NOTION_TOKEN: ${{ env.NOTION_TOKEN }}
           DATABASE_ID: ${{ env.DATABASE_ID }}

       # プルリクエスト上にコメントするステップは省略

自作のGitHub Actionsをpublicで使えるようにする

これでアクションが完成しましたが、リポジトリにビルドしたものがコミットされていないため直接実行しようとしてもエラーになってしまいます。タグがついているコミットにだけ成果物があれば良いので、GitHub Pages Actionを使って成果物は別なブランチにpushしてそこにタグをつける運用にしました。タグをつけるタイミングはmainブランチ側で行いたいので、src-v*.*.*というタグ名をつけたら、src-を除いたv*.*.*が成果物としてpushされるようにしました。

name: Publish Action

on:
  push:
    branches:
      - main
    tags:
      - "src-v*.*.*"

jobs:
  publish:
    runs-on: ubuntu-22.04
    permissions:
      contents: write
    concurrency:
      group: ${{ github.workflow }}-${{ github.ref }}

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version-file: package.json
          cache: "npm"

      - name: Install
        run: npm install

      - name: Build Action
        run: npm run build

      - name: Bundle Publish Files
        run: |
          mkdir -p publish/actions
          cp -r actions/dist publish/actions/
          cp action.yml publish/
          cp README.md publish/

      - name: Extract Version
        id: extract_version
        if: startsWith(github.ref, 'refs/tags/')
        run: |
          VERSION=${TAG_NAME#src-}
          echo "Extracted version: $VERSION"
          echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
        env:
          TAG_NAME: ${{ github.ref_name }}

      - name: Publish Action
        uses: peaceiris/actions-gh-pages@v4
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: publish
          publish_branch: dist
          tag_name: ${{ steps.extract_version.outputs.VERSION }}
          tag_message: "Publish ${{ github.ref_name }}"

その他: よりNotionのGitHubプルリクエスト連携機能に近づけたい場合

今回最低限の連携ということでリッチテキストにGitHubプルリクエストのテキストリンクを入れてますが、Notionの実装では専用のページがあり、それをリレーションしているような作りになっていました。

確かにスクショのようにGitHubプルリクエスト用のテーブルを用意してそれをリレーションすることで、かなり近いところまで再現できそうだなと思いました。特にリンクしているGitHubプルリクエストがまだ開いたままなのかマージ済みなのかを見れるのは良さそうです。この方法だと更に準備することが増えてセットアップが大変そうですが、それでも需要があればリレーションパターンも実装してみても良いかもと思いました。

終わりに

以上がGitHub ActionsでNotionのGithubプルリクエスト機能を代用した内容でした。簡易的な実装ですが、タイトルにNotionのタスクIDを入れる運用をしている場合は使えそうな感じでした。GitHub Actionsにすることで例えばアサインしたらNotionタスクにも担当者として登録するなど、色々応用もできそうです。色々試してみたい方は是非このリポジトリをForkしてカスタマイズしてみてください。

https://github.com/TakanoriOnuma/notion-task-connector-with-github-pr

Notionで今後GitHubプルリクエストとの連携が出来なくなって困る方の参考になれば幸いです。

Discussion