3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

背景

きっかけは、次の記事

Gitify で GitHub Notifications の「気付きやすさ」を上げ、会話を Issue / Pull Request に残す、という内容だった。こんな感じでやりたい!って思いが前からあった。

ただ、社内環境では次の壁があった。

  • GitHub と Teams の直接連携が無効化
  • Windows アプリの自由なインストールが難しい
  • GitHub Mobile も業務端末では使えないし、そもそも 全員に配布されてない
  • メール通知じゃ、気付きにくいし人に依っては「大量にあって見れてないです」という管理放棄発言

そこでメールを通知経路として再利用することにした。

ただし、Teams へ会話を移すのが目的ではない。Teams は気付く場所、会話と判断は GitHub に戻す。ここを最終目標として

概要

  • Outlook の GitHub 通知メールを Logic Apps で監視する
  • 対象リポジトリを 1 つに限定し、Organization 通知や別リポジトリは除外
  • Pull Request、Issue、GitHub Actions でスレッドキーを分ける
  • Teams の親メッセージ ID を SharePoint List に保存する
  • 2 通目以降は同じ Teams スレッドへ返信する
  • Teams への投稿が成功してから、元メールを既読化する
  • 既存の deploy 通知は別経路のままにして、メール経路から除外する

全体像はこんな感じ。

なぜメールを使うのか

単にそれしかないからです :sweat_smile:

んじゃ、メールをどうするか?って話

GitHub のメール通知には、判定に使える一貫したヘッダーが付いている。

From アドレス: このアドレスは常に notifications@github.com

List-Id フィールド: このフィールドはリポジトリの名前とそのオーナーを特定します。

List-Id は次の形式になる。

OWNER/REPOSITORY <REPOSITORY.OWNER>

件名だけでも [org-x/repository-a] のような文字列で絞れる。ただし件名の表現変更に弱いので、最終的には List-Id も確認する構成がよさそうだった。

Organization 全体の通知は対象外にする。

たとえば [GitHub] Your organization ... のようなメールは、対象リポジトリの List-Id と一致しない。送信者だけで判定せず、送信者とリポジトリの両方を必須条件にする。

Logic App の処理フロー

1. 新着メールを受け取る

Office 365 Outlook コネクタの 新しいメールが届いたとき (V3) を使う。

この操作は、新しい電子メールが到着したときにフローをトリガーします。

トリガー側では、まず以下だけを絞る。

項目 設定例
フォルダー 受信トレイ
差出人 notifications@github.com
添付ファイルを含める No

添付ファイルは不要。公式ドキュメントでも、添付ファイル取得を有効にすると同時受信時にタイムアウトする可能性があるとされている。

2. 対象リポジトリだけ通す

PoC では org-x/repository-a だけを対象にする。

判定は次の AND 条件にした。

From == notifications@github.com
AND List-Id == org-x/repository-a
AND Subject does not contain "Deploy Workflow"

これで次を除外できる。

  • Organization の設定や Personal Access Token 要求
  • 別リポジトリの Issue / Pull Request
  • すでに別経路で通知している deploy ワークフロー

deploy は GitHub Actions から Logic App の HTTP エンドポイントへ直接送っているため、メール通知と二重にしない。

ここ、ほんとは、同様に出来たらなと思ったけど方法が見つけられなかったのもあって、メール介すことにした。 :thinking:

3. 通知種別とスレッドキーを作る

スレッドキーは、衝突しないよう種別を含める。

repository-a/PR/123
repository-a/Issue/456
repository-a/Action/backend-check

Pull Request と Issue は番号単位。GitHub Actions はワークフロー名単位にする。

Action の成功・失敗を同じスレッドへ積めば、直近の状態と履歴がまとまって見える。deploy だけは既存通知があるので別扱い。

メールの ConversationId をそのまま Teams のスレッドキーにはしない。

Outlook の会話単位と、Teams でまとめたい業務単位が一致する保証がないため。リポジトリ、種別、番号またはワークフロー名から明示的に作る。

4. SharePoint List で親メッセージ ID を管理する

Teams の投稿アクションは、投稿結果としてメッセージ ID を返す。

PostToConversationResponse には、一意のメッセージ ID、メッセージ リンク、会話 ID が含まれます。

そこで SharePoint List を簡易な対応表として使う。

用途
Title スレッドキー。一意制約を付ける
TeamsMessageId 親投稿の ID
MessageLink Teams で開くための URL
NotificationType PR / Issue / Action
Repository 対象リポジトリ
LatestStatus Action の最新状態など
LastReceivedAt 最終受信日時

リストにキーがなければ チャットまたはチャネルでメッセージを投稿する。あれば保存済み ID を使って チャネルでメッセージを返信する

チャネルでメッセージを返信する: チャネルのメッセージにメッセージを返信する。

5. 成功後にメールを既読化する

最後に Outlook コネクタの 開封済みまたは未読としてマークする (V3) を実行する。

重要なのは順番。

  1. Teams への投稿
  2. SharePoint List の登録または更新
  3. メールを既読化

先に既読化すると、Teams 投稿に失敗しても処理済みに見えてしまう。

この操作により、メールが開封済み/未読としてマークされます。

Outlook ルールも併用する

既存の deploy 通知は、すでに別経路から Teams へ届いている。メールまで未読で残す必要はない。

そこで Outlook on the web にルールを追加した。

差出人: notifications@github.com
件名: Deploy Workflow
件名または本文: [org-x/repository-a]
操作: 開封済みにする

このルールは動作確認済み。Logic App 側でも同じ deploy メールを除外し、二重通知と未読ノイズの両方を減らす。

Azure リソースの作成

まずは次を専用のリソース グループへ分離した。

  • Consumption Logic App
  • Office 365 Outlook API Connection
  • Microsoft Teams API Connection
  • SharePoint Online API Connection

アプリケーションコードはないため、azd provision から

azd auth login
azd provision --preview
azd provision --no-prompt

azd provision で Azure リソースの作成まで確認済み。API Connection の OAuth 認証は対話操作になるため、Azure portal で各接続を認証してから Logic App を有効化する。

API Connection は個人アカウントへ依存する。

退職・異動・パスワードや条件付きアクセスの変更で止まる可能性があって、個人アカウントってのが運用では微妙なので、システムアカウントとかにすべきだとは思うけど、まぁ、まずはやってみる、の姿勢なので。 :sweat:

ハマりそうな点

メール通知はリアルタイム保証ではない

Office 365 Outlook コネクタは、まれにトリガーが最大 1 時間遅延することがある。また、同時に多数のメールが届くと一部を見逃す可能性も公式に記載されている。

そのため、この構成は人が気付くための通知には使えるが、即時性と完全性が必須の監視経路には向かなさそう

メール本文を丸ごと投稿しない

Private リポジトリの通知には、Issue や Pull Request の本文が含まれることがある。

Teams には次だけを投稿する。

  • 種別と番号
  • タイトル
  • 更新者
  • Action の成功・失敗
  • GitHub へのリンク

メール本文や添付ファイルは載せない。Logic Apps の実行履歴に機密情報を残したくない場合は、該当アクションの Secure Inputs / Secure Outputs も有効化する。

重複実行に備える

メールトリガーは条件によって重複実行される可能性がある。SharePoint List の Title を一意にし、メールの InternetMessageId も処理済み ID として保持すると安全。まぁ、通知だし、その辺あんま考えすぎないほうが良い。LogicAppsでは重複Triggerはあり得るものだから。 :sweat_smile:

あとがき

Gitify を入れられるなら、GitHub へ直接戻れるのでかなり素直だと思う。

でも、アプリ導入も直接連携も難しい環境では、すでに許可されているメールでなんとかするしかない。GitHub 通知メールは定型であり、リポジトリ判定用のヘッダーもあるので、単なる件名転送より堅く組めたきがする。今後の運用次第ではあるけれど・・ :thinking:

ただし、Teams で会話を始めると本末転倒。通知には GitHub のリンクを必ず付け、返信先は Issue / Pull Request にする。 ここが大事だけど、今のチームで上手くいくかは不明。文化醸成はどんな時でも大切ですよね :sweat_smile:

ってことで、メールは古い仕組みというより、制約の多い環境でサービス間をつなぐ許可済みの経路としては、まだまだ使えますね、と

付録: Logic App の実装コード

Logic App のワークフロー本体は JSON で定義する。今回の実装で使った workflow-definition.json を、環境依存値を置き換えた形で供養しておく。

次の値はデプロイ先に合わせて変更する。

プレースホルダー 置き換える値
REPLACE_SHAREPOINT_SITE_URL SharePoint サイト URL
REPLACE_TEAM_ID Teams のチーム ID
REPLACE_CHANNEL_ID Teams の標準チャネル ID
REPLACE_ORG/REPOSITORY 対象 GitHub リポジトリ
REPLACE_REPOSITORY スレッドキーに使うリポジトリ名
REPLACE_DEPLOY_KEYWORD 別経路で通知済みの deploy 通知に含まれる語句
$connections Azure portal で認証した API Connection の参照

$connections の実値、Team ID、Channel ID、SharePoint URL はソース管理へそのまま保存しない。接続の OAuth 認証は Azure portal またはデプロイ環境側で設定する。

{
    "$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
        "$connections": {
            "type": "Object",
            "defaultValue": {}
        },
        "sharePointSiteUrl": {
            "type": "String",
            "defaultValue": "REPLACE_SHAREPOINT_SITE_URL"
        },
        "sharePointListName": {
            "type": "String",
            "defaultValue": "GitHubNotificationThreads"
        },
        "teamId": {
            "type": "String",
            "defaultValue": "REPLACE_TEAM_ID"
        },
        "channelId": {
            "type": "String",
            "defaultValue": "REPLACE_CHANNEL_ID"
        },
        "repositoryPrefix": {
            "type": "String",
            "defaultValue": "[REPLACE_ORG/REPOSITORY]"
        },
        "repositoryShort": {
            "type": "String",
            "defaultValue": "REPLACE_REPOSITORY"
        },
        "repository": {
            "type": "String",
            "defaultValue": "REPLACE_ORG/REPOSITORY"
        },
        "excludedKeyword": {
            "type": "String",
            "defaultValue": "REPLACE_DEPLOY_KEYWORD"
        }
    },
    "triggers": {
        "On_New_Email_V3": {
            "type": "ApiConnection",
            "inputs": {
                "host": {
                    "connection": {
                        "name": "@parameters('$connections')['office365']['connectionId']"
                    }
                },
                "method": "get",
                "path": "/v3/Mail/OnNewEmail",
                "queries": {
                    "folderPath": "Inbox",
                    "importance": "Any",
                    "fetchOnlyWithAttachment": false,
                    "includeAttachments": false,
                    "from": "notifications@github.com",
                    "subjectFilter": "@parameters('repositoryPrefix')"
                }
            },
            "recurrence": {
                "frequency": "Minute",
                "interval": 1
            },
            "runtimeConfiguration": {
                "concurrency": {
                    "runs": 1
                }
            },
            "splitOn": "@triggerBody()?['value']"
        }
    },
    "actions": {
        "Compose_Subject": {
            "type": "Compose",
            "inputs": "@coalesce(triggerBody()?['subject'], '')",
            "runAfter": {}
        },
        "Compose_Tail": {
            "type": "Compose",
            "inputs": "@if(startsWith(outputs('Compose_Subject'), parameters('repositoryPrefix')), trim(substring(outputs('Compose_Subject'), length(parameters('repositoryPrefix')))), '')",
            "runAfter": {
                "Compose_Subject": [
                    "Succeeded"
                ]
            }
        },
        "Compose_Action_WorkflowName": {
            "type": "Compose",
            "inputs": "@if(startsWith(outputs('Compose_Tail'), 'Run succeeded: '), first(split(substring(outputs('Compose_Tail'), length('Run succeeded: ')), ' - ')), if(startsWith(outputs('Compose_Tail'), 'Run failed: '), first(split(substring(outputs('Compose_Tail'), length('Run failed: ')), ' - ')), if(startsWith(outputs('Compose_Tail'), 'Run cancelled: '), first(split(substring(outputs('Compose_Tail'), length('Run cancelled: ')), ' - ')), ''))) )",
            "runAfter": {
                "Compose_Tail": [
                    "Succeeded"
                ]
            }
        },
        "Compose_PR_NumberCandidate": {
            "type": "Compose",
            "inputs": "@if(and(contains(outputs('Compose_Subject'), '(PR #'), contains(last(split(outputs('Compose_Subject'), '(PR #')), ')')), first(split(last(split(outputs('Compose_Subject'), '(PR #')), ')')), '')",
            "runAfter": {
                "Compose_Action_WorkflowName": [
                    "Succeeded"
                ]
            }
        },
        "Compose_Issue_NumberCandidate": {
            "type": "Compose",
            "inputs": "@if(and(contains(outputs('Compose_Tail'), '(#'), contains(last(split(outputs('Compose_Tail'), '(#')), ')')), first(split(last(split(outputs('Compose_Tail'), '(#')), ')')), if(and(contains(outputs('Compose_Tail'), '(Issue #'), contains(last(split(outputs('Compose_Tail'), '(Issue #')), ')')), first(split(last(split(outputs('Compose_Tail'), '(Issue #')), ')')), ''))",
            "runAfter": {
                "Compose_PR_NumberCandidate": [
                    "Succeeded"
                ]
            }
        },
        "Compose_NotificationType": {
            "type": "Compose",
            "inputs": "@if(and(startsWith(outputs('Compose_Subject'), parameters('repositoryPrefix')), not(contains(outputs('Compose_Subject'), parameters('excludedKeyword'))), or(startsWith(outputs('Compose_Tail'), 'Run succeeded: '), startsWith(outputs('Compose_Tail'), 'Run failed: '), startsWith(outputs('Compose_Tail'), 'Run cancelled: ')), contains(outputs('Compose_Tail'), ' - '), not(empty(outputs('Compose_Action_WorkflowName')))), 'Action', if(and(startsWith(outputs('Compose_Subject'), parameters('repositoryPrefix')), not(contains(outputs('Compose_Subject'), parameters('excludedKeyword'))), contains(outputs('Compose_Subject'), '(PR #'), isInt(outputs('Compose_PR_NumberCandidate'))), 'PR', if(and(startsWith(outputs('Compose_Subject'), parameters('repositoryPrefix')), not(contains(outputs('Compose_Subject'), parameters('excludedKeyword'))), not(and(or(startsWith(outputs('Compose_Tail'), 'Run succeeded: '), startsWith(outputs('Compose_Tail'), 'Run failed: '), startsWith(outputs('Compose_Tail'), 'Run cancelled: ')), contains(outputs('Compose_Tail'), ' - '), not(empty(outputs('Compose_Action_WorkflowName'))))), not(contains(outputs('Compose_Subject'), '(PR #')), or(contains(outputs('Compose_Tail'), '(#'), contains(outputs('Compose_Tail'), '(Issue #')), isInt(outputs('Compose_Issue_NumberCandidate'))), 'Issue', ''))) ",
            "runAfter": {
                "Compose_Issue_NumberCandidate": [
                    "Succeeded"
                ]
            }
        },
        "Compose_NotificationNumber": {
            "type": "Compose",
            "inputs": "@if(equals(outputs('Compose_NotificationType'), 'PR'), string(int(outputs('Compose_PR_NumberCandidate'))), if(equals(outputs('Compose_NotificationType'), 'Issue'), string(int(outputs('Compose_Issue_NumberCandidate'))), ''))",
            "runAfter": {
                "Compose_NotificationType": [
                    "Succeeded"
                ]
            }
        },
        "Compose_ThreadKey": {
            "type": "Compose",
            "inputs": "@if(equals(outputs('Compose_NotificationType'), 'Action'), concat(parameters('repositoryShort'), '/Action/', outputs('Compose_Action_WorkflowName')), if(equals(outputs('Compose_NotificationType'), 'PR'), concat(parameters('repositoryShort'), '/PR/', outputs('Compose_NotificationNumber')), if(equals(outputs('Compose_NotificationType'), 'Issue'), concat(parameters('repositoryShort'), '/Issue/', outputs('Compose_NotificationNumber')), ''))) ",
            "runAfter": {
                "Compose_NotificationNumber": [
                    "Succeeded"
                ]
            }
        },
        "Compose_LatestStatus": {
            "type": "Compose",
            "inputs": "@if(equals(outputs('Compose_NotificationType'), 'Action'), if(startsWith(outputs('Compose_Tail'), 'Run succeeded: '), 'success', if(startsWith(outputs('Compose_Tail'), 'Run failed: '), 'failure', 'cancelled')), 'updated')",
            "runAfter": {
                "Compose_ThreadKey": [
                    "Succeeded"
                ]
            }
        },
        "Compose_TeamsHtml": {
            "type": "Compose",
            "inputs": "@concat('<strong>[', outputs('Compose_NotificationType'), '] ', outputs('Compose_ThreadKey'), '</strong><br/>Status: ', outputs('Compose_LatestStatus'), '<br/>', replace(replace(replace(take(coalesce(triggerBody()?['bodyPreview'], ''), 255), '&', '&amp;'), '<', '&lt;'), '>', '&gt;'), '<br/>Received: ', coalesce(triggerBody()?['receivedDateTime'], ''))",
            "runAfter": {
                "Compose_LatestStatus": [
                    "Succeeded"
                ]
            }
        },
        "Condition_Processable": {
            "type": "If",
            "expression": "@not(empty(outputs('Compose_NotificationType')))",
            "actions": {
                "Get_Items": {
                    "type": "ApiConnection",
                    "inputs": {
                        "host": {
                            "connection": {
                                "name": "@parameters('$connections')['sharepointonline']['connectionId']"
                            }
                        },
                        "method": "get",
                        "path": "/datasets/@{encodeURIComponent(encodeURIComponent(parameters('sharePointSiteUrl')))}/tables/@{encodeURIComponent(encodeURIComponent(parameters('sharePointListName')))}/items",
                        "queries": {
                            "$filter": "@concat('Title eq ''', replace(outputs('Compose_ThreadKey'), '''', ''''''), '''')",
                            "$top": 1
                        }
                    },
                    "runAfter": {}
                },
                "Condition_Existing_Thread": {
                    "type": "If",
                    "expression": "@and(greater(length(body('Get_Items')?['value']), 0), not(empty(string(first(body('Get_Items')?['value'])?['TeamsMessageId']))))",
                    "actions": {
                        "Reply_To_Conversation": {
                            "type": "ApiConnection",
                            "inputs": {
                                "host": {
                                    "connection": {
                                        "name": "@parameters('$connections')['teams']['connectionId']"
                                    }
                                },
                                "method": "post",
                                "path": "/beta/teams/conversation/message/poster/@{encodeURIComponent('Flow bot')}/location/@{encodeURIComponent('Channel')}/reply",
                                "body": {
                                    "recipient": {
                                        "groupId": "@parameters('teamId')",
                                        "channelId": "@parameters('channelId')"
                                    },
                                    "messageBody": "@outputs('Compose_TeamsHtml')",
                                    "parentMessageId": "@string(first(body('Get_Items')?['value'])?['TeamsMessageId'])"
                                }
                            },
                            "runAfter": {}
                        },
                        "Update_Existing_Item_After_Reply": {
                            "type": "ApiConnection",
                            "inputs": {
                                "host": {
                                    "connection": {
                                        "name": "@parameters('$connections')['sharepointonline']['connectionId']"
                                    }
                                },
                                "method": "patch",
                                "path": "/datasets/@{encodeURIComponent(encodeURIComponent(parameters('sharePointSiteUrl')))}/tables/@{encodeURIComponent(encodeURIComponent(parameters('sharePointListName')))}/items/@{encodeURIComponent(string(first(body('Get_Items')?['value'])?['ID']))}",
                                "body": {
                                    "LatestStatus": "@outputs('Compose_LatestStatus')",
                                    "LastReceivedAt": "@triggerBody()?['receivedDateTime']"
                                }
                            },
                            "runAfter": {
                                "Reply_To_Conversation": [
                                    "Succeeded"
                                ]
                            }
                        },
                        "Mark_As_Read_After_Reply": {
                            "type": "ApiConnection",
                            "inputs": {
                                "host": {
                                    "connection": {
                                        "name": "@parameters('$connections')['office365']['connectionId']"
                                    }
                                },
                                "method": "post",
                                "path": "/v3/MarkAsRead",
                                "body": {
                                    "MessageId": "@triggerBody()?['id']",
                                    "IsRead": true
                                }
                            },
                            "runAfter": {
                                "Update_Existing_Item_After_Reply": [
                                    "Succeeded"
                                ]
                            }
                        }
                    },
                    "else": {
                        "actions": {
                            "Post_Parent_Message": {
                                "type": "ApiConnection",
                                "inputs": {
                                    "host": {
                                        "connection": {
                                            "name": "@parameters('$connections')['teams']['connectionId']"
                                        }
                                    },
                                    "method": "post",
                                    "path": "/beta/teams/conversation/message/poster/@{encodeURIComponent('Flow bot')}/location/@{encodeURIComponent('Channel')}",
                                    "body": {
                                        "recipient": {
                                            "groupId": "@parameters('teamId')",
                                            "channelId": "@parameters('channelId')"
                                        },
                                        "messageBody": "@outputs('Compose_TeamsHtml')"
                                    }
                                },
                                "runAfter": {}
                            },
                            "Create_Item_With_Parent": {
                                "type": "ApiConnection",
                                "inputs": {
                                    "host": {
                                        "connection": {
                                            "name": "@parameters('$connections')['sharepointonline']['connectionId']"
                                        }
                                    },
                                    "method": "post",
                                    "path": "/datasets/@{encodeURIComponent(encodeURIComponent(parameters('sharePointSiteUrl')))}/tables/@{encodeURIComponent(encodeURIComponent(parameters('sharePointListName')))}/items",
                                    "body": {
                                        "Title": "@outputs('Compose_ThreadKey')",
                                        "TeamsMessageId": "@body('Post_Parent_Message')?['id']",
                                        "MessageLink": "@coalesce(body('Post_Parent_Message')?['messageLink'], '')",
                                        "NotificationType": "@outputs('Compose_NotificationType')",
                                        "Repository": "@parameters('repository')",
                                        "LatestStatus": "@outputs('Compose_LatestStatus')",
                                        "LastReceivedAt": "@triggerBody()?['receivedDateTime']"
                                    }
                                },
                                "runAfter": {
                                    "Post_Parent_Message": [
                                        "Succeeded"
                                    ]
                                }
                            },
                            "Mark_As_Read_After_Parent_Save": {
                                "type": "ApiConnection",
                                "inputs": {
                                    "host": {
                                        "connection": {
                                            "name": "@parameters('$connections')['office365']['connectionId']"
                                        }
                                    },
                                    "method": "post",
                                    "path": "/v3/MarkAsRead",
                                    "body": {
                                        "MessageId": "@triggerBody()?['id']",
                                        "IsRead": true
                                    }
                                },
                                "runAfter": {
                                    "Create_Item_With_Parent": [
                                        "Succeeded"
                                    ]
                                }
                            }
                        }
                    },
                    "runAfter": {
                        "Get_Items": [
                            "Succeeded"
                        ]
                    }
                }
            },
            "else": {
                "actions": {}
            },
            "runAfter": {
                "Compose_TeamsHtml": [
                    "Succeeded"
                ]
            }
        }
    },
    "outputs": {}
}

この JSON は Logic App のワークフロー定義であり、API Connection 自体や SharePoint List は含まない。Bicep から loadJsonContent で読み込む場合は、接続リソースの connectionId と managed API ID を $connections に注入する。

上の定義は匿名化済みの掲載用サンプル。実際のデプロイ前に、プレースホルダーの置換、SharePoint List の列名、Teams の Workflows アプリ許可、OAuth 接続を確認すること。

まぁ、実際には運用してみてまだ修正することが色々あるとは思うので、明日以降運用してみてどうなるか・・乞うご期待 :laughing:

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?