Hello World|Labs
Published on

n8nを使ったイシュー管理フローの作成

前の記事でWebアプリケーションで発生したエラーを通知する簡単なワークフローを作成しました。 本記事では通知されたエラーをGitHubのIssueを使って管理するワークフローを作成します。

フロー概要

今回は2つのワークフローを作成します。 一つはエラーをGitHubにIssue登録を行うフロー、もう一つはGitHubに登録されているIssueからオープン中のIssueを通知するフローです。

GitHubにIssue登録を行うフローは以下のようになります。チャットツールからIssue登録のリクエストをバックエンドサービスに送信し、バックエンドサービスでエラー情報の取得を行ったうえでGitHubにIssue登録を行います。

GitHubに登録されているIssueからオープン中のIssueを通知するフローは以下のようになります。GitHubに登録されているIssueをバックエンドサービスのDBに同期します。同期したデータからオープン中のIssueを抽出しチャットツールに通知します。

システム構成

今回のシステム構成は下図のようになります。Mattermostからn8nを介してGitHubにIssueを登録します。IssueはAirbyteによってDBに同期され、オープンのままになっているIssueをn8nを介してMattermostに通知します。

ResourceUsageHosting TypeLicensing Model
Caddyバックエンドサービスに対するHTTPS通信を中継、SSLオフロードやIPアドレスによるアクセス制限などを行うSelf Hosting(Docker container on Hetzner Cloud)Free
Open Source
Mattermostエラー内容の通知先Self Hosting(Docker container on Hetzner Cloud)Freemium
Open Source
n8nワークフローを実行するSelf Hosting(Docker container on Hetzner Cloud)Freemium
Open Source
GlitchTipエラーのトラッキングを行うSelf Hosting(Docker container on Hetzner Cloud)Freemium
Open Source
AirbyteGitHubからIssueを取得しDBに同期するSelf Hosting(Docker container on Hetzner Cloud)Freemium
Open Source
PostgreSQLIssueの保存および各バックエンドサービスのデータストアとして使用するSelf Hosting(Docker container on Hetzner Cloud)Free
Open Source

フローの作成

Issueの登録

登録イベントの送信

GitHubのIssueの登録は、チャットツールに通知されたエラーに対して特定のリアクションを行った場合に、自動的に行われるようにします。 今回は、下図のようにカスタムemojiを追加し、そのemojiが使われたらIssue登録のイベントをワークフローエンジンに送信します。

emojiによるリアクションをトリガーにしてイベントを送信する機能はMattermostには標準で搭載されていないため、独自のプラグインを作成します。プラグインの作成はテンプレートを使って行います。

git clone --depth 1 https://github.com/mattermost/mattermost-plugin-starter-template mattermost-plugin

テンプレートをダウンロードしたらマニフェストファイルのsettings_schemaにプラグインの設定項目を追加します。

参考:https://developers.mattermost.com/integrate/plugins/manifest-reference/

{
    "id": "please input your plugin id",
    "name": "please input your plugin name",
    "description": "please input your plugin description",
    "homepage_url": "https://github.com/mattermost/mattermost-plugin-starter-template",
    "support_url": "https://github.com/mattermost/mattermost-plugin-starter-template/issues",
    "icon_path": "assets/starter-template-icon.svg",
    "min_server_version": "6.2.1",
    "server": {
        "executables": {
            "linux-amd64": "server/dist/plugin-linux-amd64",
            "linux-arm64": "server/dist/plugin-linux-arm64",
            "darwin-amd64": "server/dist/plugin-darwin-amd64",
            "darwin-arm64": "server/dist/plugin-darwin-arm64",
            "windows-amd64": "server/dist/plugin-windows-amd64.exe"
        }
    },
    "webapp": {
        "bundle_path": "webapp/dist/main.js"
    },
    "settings_schema": {
        "header": "Outgoing webhook plugin triggered by emoji reactions.",
        "footer": "",
        "settings": [
            {
                "key": "WebhookList",
                "display_name": "Webhook list",
                "type": "longtext",
                "help_text": "",
                "hosting": "on-prem"
            }
        ]
    }
}

今回はWebhookListという複数行テキストの項目を追加して、そこにWebhookの設定をJSON形式で入力して行うようにします。 次にserver/plugin.goファイルにemojiのリアクションが付けられた時の処理を実装します。

package main
 
import (
	"bytes"
	"fmt"
	"log/slog"
	"net/http"
	"regexp"
	"sync"
 
	"github.com/mattermost/mattermost/server/public/model"
	"github.com/mattermost/mattermost/server/public/plugin"
	"github.com/mattermost/mattermost/server/public/pluginapi"
)
 
type Webhook struct {
	Emoji     string `json:"emoji"`
	Endpoint  string `json:"endpoint"`
	AuthToken string `json:"auth_token"`
}
 
type Plugin struct {
	plugin.MattermostPlugin
	client *pluginapi.Client
 
	configurationLock sync.RWMutex
 
	configuration *configuration
 
	webhookMap map[string]Webhook
}
 
func (p *Plugin) OnActivate() error {
	if p.client == nil {
		p.client = pluginapi.NewClient(p.API, p.Driver)
	}
 
	return nil
}
 
func (p *Plugin) ReactionHasBeenAdded(c *plugin.Context, reaction *model.Reaction) {
	post, appErr := p.client.Post.GetPost(reaction.PostId)
 
	if appErr != nil {
		return
	}
 
	re := regexp.MustCompile(`Issue ID: (.*)`)
	match := re.FindStringSubmatch(post.Message)
 
	var issueID string
	if len(match) > 1 {
		issueID = match[1]
	} else {
		return
	}
 
	webhook, ok := p.webhookMap[reaction.EmojiName]
	if !ok {
		slog.Info("Webhook not found", "info", reaction.EmojiName)
		return
	}
 
	url := webhook.Endpoint
	data := []byte(fmt.Sprintf(`{"issue_id": "%s"}`, issueID))
 
	req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
	if err != nil {
		slog.Error("Failed to create Webhook request", "error", err)
		return
	}
 
	req.Header.Set("Authorization", "Bearer "+webhook.AuthToken)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json")
 
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		slog.Error("Failed to request Webhook", "error", err)
		return
	}
 
	defer resp.Body.Close()
 
	if resp.StatusCode != 200 {
		slog.Info("Webhook request was not successful", "info", resp.StatusCode)
	}
}
 
func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, "Hello, world!")
}

ReactionHasBeenAddedフックの中でemojiに紐づくWebhookの情報を取得し、Webhookに対してIssue IDをPOST送信しています。 次にserver/configuration.goファイルのOnConfigurationChangeイベントハンドラにWebhookの設定を読み込む処理を追加します。

func (p *Plugin) OnConfigurationChange() error {
	if p.client == nil {
		p.client = pluginapi.NewClient(p.API, p.Driver)
	}
 
	var configuration = new(configuration)
 
	// Load the public configuration fields from the Mattermost server configuration.
	if err := p.API.LoadPluginConfiguration(configuration); err != nil {
		return errors.Wrap(err, "failed to load plugin configuration")
	}
 
	var webhooks []Webhook
 
	bytes := []byte(configuration.WebhookList)
	err := json.Unmarshal(bytes, &webhooks)
	if err != nil {
		slog.Error("WebhookList misconfiguration", "error", err.Error())
	}
 
	p.webhookMap = make(map[string]Webhook)
	for _, webhook := range webhooks {
		p.webhookMap[webhook.Emoji] = webhook
	}
 
	p.setConfiguration(configuration)
 
	return nil
}

WebhookListに設定されたJSONテキストをwebhookMapに展開しています。プラグラムの修正が完了したらmakeコマンドでプラグインをビルドします。

$ make
 
./build/bin/manifest check
./build/bin/manifest apply
 
 
plugin built at: dist/dev.86world.emoji.webhook-0.0.0+4e15acf.tar.gz

ビルドが完了したらSystem ConsolePlugin Managementからプラグイン(tar.gzファイル)をアップロードします。

アップロードが完了したらプラグインの設定画面でWebhook listの入力欄にWebhookの設定をJSON形式で入力します。

[{"emoji": "github", "endpoint": "エンドポイントのURL", "auth_token": "認証用トークン"}]

これでワークフローエンジンに登録イベントを送信することができるようになりましたので、次にワークフローエンジンでIssue登録のワークフローを作成していきます。

Issueの登録

まず登録イベントを受信するWebフックを作成します(ノードパネルからOn webhook callを選択)。

ノードを追加して設定パネルで以下のように設定します。

NameValue
HTTP MethodPOST
Path任意のパスを設定
AuthenticationHeader Auth
Credential for Header AuthNameにAuthorization、ValueにBearer <Mattermostのプラグインに設定したトークン>を設定
RespondImmediately
Response Code200

次に受信したPOSTデータのIssue IDからエラー情報の詳細を取得します。 エラー情報の詳細はGitHubのIssue登録の際のタイトルと本文に使用します。 エラー情報の詳細は前の記事のエラー通知のワークフローでDBに保存していますのでそこから取得します。

Postgresノードを追加して設定パネルで以下のように設定します。

NameValue
Credential to connect withDB接続情報を設定
OperationExecute Query
Query下記のSQLクエリを入力
Options - Query Parameters{{ $json.body.issue_id }}
SELECT 
  iss.id, 
  iss.level, 
  iss.metadata, 
  iss.title, 
  iss.last_seen, 
  issev.data 
FROM 
  issue_events_issue iss 
  INNER JOIN issue_events_issueevent issev ON iss.id = issev.issue_id 
WHERE 
  iss.id = $1 
  AND iss.is_deleted = false 
  AND iss.status = 0 
ORDER BY 
  issev.received DESC 
LIMIT 
  1

取得したエラー情報にはスタックトレース情報が含まれていますので、それをIssueの本文に使用します。 そのためにCodeノードでデータの加工を行います(ノードパネルのData transformationから選択)。

ノードを追加して設定パネルのコード入力欄に以下のJavaScriptを入力します。

for (const item of $input.all()) {
  const type = item.json.data.exception[0].type
  const value = item.json.data.exception[0].value
  const frames = item.json.data.exception[0].stacktrace.frames.reverse()
  let stacktrace = type + ':' + value + '\n'
  for (const frame of frames) {
    stacktrace += 'at ' + frame.function + '(' + frame.filename + ':' + frame.lineno + ':' + frame.colno + ')\n'
  }
  item.json.stacktrace = stacktrace
}
 
return $input.all()

次にGitHubに重複してIssueを登録しないようにPostgresノードでDB照会を行います。

DBに以下のようなテーブルを作成してGitHubへのIssue登録を管理するようにし、重複判定はこのテーブルへの登録の有無で行います。

# \d issue_tickets
                                       Table "public.issue_tickets"
   Column    |           Type           | Collation | Nullable |                  Default
-------------+--------------------------+-----------+----------+-------------------------------------------
 id          | integer                  |           | not null | nextval('issue_tickets_id_seq'::regclass)
 issue_id    | bigint                   |           | not null |
 gh_issue_id | character varying        |           | not null |
 url         | character varying        |           | not null |
 created_at  | timestamp with time zone |           |          | now()
 updated_at  | timestamp with time zone |           |          | now()

ノードを追加して設定パネルで以下のように設定します。

NameValue
Credential to connect withDB接続情報を設定
OperationSelect
Tableissue_tickets
Limit1
Columnissue_id
OperatorEqual
Value{{ $json.id }}

次にIFノードを追加して該当データがある場合はワークフローを終了、ない場合はIssue登録へ進むようにします(ノードパネルのFlowから選択)。

ノードを追加して設定パネルで以下のように設定します。

NameValue
ConditionsBoolean
Value{{ $json.isEmpty() }}
Operationis true

trueブランチにGitHubノードを追加してIssueの登録を行います(ノードパネルのAction in an appから選択)。

ノードを追加して設定パネルで以下のように設定します。

NameValue
Credential to connect withGitHubで発行したパーソナルアクセストークンを設定
ResourceIssue
OperationCreate
Repository Owner該当のリポジトリオーナーを入力
Repository Name該当のリポジトリ名を入力
Title{{ $('エラー詳細取得ノードの名前').item.json.title }}
Body{{ $('スタックトレース加工ノードの名前').item.json.stacktrace }}

Issueの登録に成功したらPostgresノードでissue_ticketsテーブルに登録情報を保存します。

ノードを追加して設定パネルで以下のように設定します。

NameValue
Credential to connect withDB接続情報を設定
OperationInsert
Tableissue_tickets
Mapping Column ModeMap Each Column Mnually
issue_id{{ $('スタックトレース加工ノードの名前').item.json.id }}
gh_issue_id{{ $json.id }}
url{{ $json.html_url }}

最後にGlitchTipのIssueのコメント欄にGitHubのIssueへのリンクを投稿します。 コメント欄への投稿はREST APIで行います。

HTTP Requestノードを追加して設定パネルで以下のように設定します(ノードパネルのHelpersから選択)。

NameValue
MethodPOST
URLhttp://(GlitchTipのサービス名):8000/api/0/issues/{{ $json.issue_id }}/comments/
AuthenticationGeneric Credential Type
Generic Auth TypeHeader Auth
Credential for Header AuthAuthorization Bearer <GlitchTipで発行したトークン>
Send HeadersON
Header Parameters (Name1)Content-Type
Header Parameters (Value1)application/json
Header Parameters (Name2)Accept
Header Parameters (Value2)application/json
Send BodyON
Body Content TypeJSON
Specify BodyUsing JSON
JSON{"data":{"text":"{{ $json.url }}"}}

ここまででIssue登録のワークフローは完成となりますので実際に実行してみます。 下図のようにGitHubにIssueが登録され、GlitchTipにIssueへリンクされていることが確認できます。

オープン中Issueの通知

次にオープン中のIssueを通知するワークフローを作成します。

Issueの同期

GitHubのIssueをDBに同期するためにETLツールのAirbyteを使用します。 Airbyteの管理画面でsourceにGitHubを追加し、アクセストークンとリポジトリの設定を行います。

次にdestinationにPostgreSQLを追加し、DB接続情報の設定を行います。

最後にconnectionを作成します。sourceとdestinationは先ほど作成したGitHubとPostgreSQLを選択します。 同期モードはReplicate Source、ストリームはissuesを選択し、ストリーム接頭辞にgh_を設定します。 これでgh_issuesというテーブル名でIssueの情報がDBに同期されます。

手動でJobを実行してみます。

Jobが完了すると以下のようなテーブルにIssueの情報が同期されます。

\d gh_issues
                               Table "public.gh_issues"
          Column          |           Type           | Collation | Nullable | Default
--------------------------+--------------------------+-----------+----------+---------
 id                       | bigint                   |           |          |
 url                      | character varying        |           |          |
 body                     | character varying        |           |          |
 user                     | jsonb                    |           |          |
 draft                    | boolean                  |           |          |
 state                    | character varying        |           |          |
 title                    | character varying        |           |          |
 labels                   | jsonb                    |           |          |
 locked                   | boolean                  |           |          |
 number                   | bigint                   |           |          |
 node_id                  | character varying        |           |          |
 user_id                  | bigint                   |           |          |
 assignee                 | jsonb                    |           |          |
 comments                 | bigint                   |           |          |
 html_url                 | character varying        |           |          |
 assignees                | jsonb                    |           |          |
 closed_at                | timestamp with time zone |           |          |
 milestone                | jsonb                    |           |          |
 reactions                | jsonb                    |           |          |
 created_at               | timestamp with time zone |           |          |
 events_url               | character varying        |           |          |
 labels_url               | character varying        |           |          |
 repository               | character varying        |           |          |
 updated_at               | timestamp with time zone |           |          |
 comments_url             | character varying        |           |          |
 pull_request             | jsonb                    |           |          |
 state_reason             | character varying        |           |          |
 timeline_url             | character varying        |           |          |
 repository_url           | character varying        |           |          |
 active_lock_reason       | character varying        |           |          |
 author_association       | character varying        |           |          |
 performed_via_github_app | jsonb                    |           |          |
 _airbyte_raw_id          | character varying(36)    |           | not null |
 _airbyte_extracted_at    | timestamp with time zone |           | not null |
 _airbyte_meta            | jsonb                    |           | not null |

Airbyteの設定が完了しましたのでワークフローを作成していきます。

ワークフローは以下の2種類の方法で起動します。

  • 1日1回のスケジュール起動(毎日午前0時)
  • Issue登録のワークフローが完了した後に起動

まずはワークフローをスケジュール起動するためのScheduleノード(ノードパネルからOn a scheduleを選択)とWorkflowノード(ノードパネルからWhen called by another workflowを選択)を追加します。

Scheduleノードは設定パネルで以下のように設定します。

NameValue
Trigger IntervalDays
Days Between Triggers1
Trigger at HourMidnight
Trigger at Minute0

次にAibyteで作成したconnectionの同期ジョブをREST API経由で実行するためにHTTP Requestノードを追加します。

ノードを追加して設定パネルで以下のように設定します。

NameValue
MethodPOST
URLhttp://(Airbyte APIサーバーのサービス名):8006/v1/jobs
AuthenticationGeneric Credential Type
Generic Auth TypeBasic Auth
Credential for Basic Authコンテナ起動時の環境変数BASIC_AUTH_USERNAMEBASIC_AUTH_PASSWORDを設定
Send HeadersON
Header Parameters (Name1)Accept
Header Parameters (Value1)application/json
Header Parameters (Name2)Content-Type
Header Parameters (Value2)application/json
Send BodyON
Body Content TypeJSON
Specify BodyUsing JSON
JSON{"connectionId": "作成したコネクションのID", "jobType":"sync"}

同期ジョブがスタートしたらジョブが完了するまでジョブステータスを監視します。 待機ノードで10秒間待機してREST API経由でジョブステータスを取得し、ジョブが完了していない場合は再度待機してステータスを取得するという繰り返しのフローを作成します。

まずWaitノードを追加して設定パネルで以下のように設定します。

NameValue
ResumeAfter Time Interval
Wait Amount10
Wait UnitSeconds

次にHTTP Requestノードを追加して設定パネルで以下のように設定します。

NameValue
MethodGET
URLhttp://(Airbyte APIサーバーのサービス名):8006/v1/jobs/{{ $('同期ジョブ開始ノードの名前').item.json.jobId }}
AuthenticationGeneric Credential Type
Generic Auth TypeBasic Auth
Credential for Basic Authコンテナ起動時の環境変数BASIC_AUTH_USERNAMEBASIC_AUTH_PASSWORDを設定
Send HeadersON
Header Parameters (Name1)Accept
Header Parameters (Value1)application/json

Switchノードを追加して設定パネルで以下のように設定し、取得したステータスに応じてステータスの監視を継続するか後続の処理に進むかを切り替えます(ノードパネルのFlowから選択)。

NameValue
ModeRule
Routing Rule1{{ $json.status }} is euaul to succeeded
Output Namesucceeded
Routing Rule2{{ $json.status }} is euaul to running
Output Namerunning

runningブランチをWaitノードに接続してステータスがrunningの場合はステータス監視を繰り返すようにします。 succeededブランチにPostgresノードを追加して同期テーブルからオープン中のIssueを取得します。

ノードを追加して設定パネルで以下のように設定します。

NameValue
Credential to connect withDB接続情報を設定
OperationSelect
Tablegh_issues
Return AllON
Columnclosed_at
OperatorIs Null

担当者がアサインされていないIssueも通知できるようにするためにCodeノードで情報を加工します。

ノードを追加して設定パネルのコード入力欄に以下のJavaScriptを入力します。

const open = [], noAssignee = []
 
for (const item of $input.all()) {
  open.push(item.json.html_url)
  
  if (item.json.assignee === null) {
    noAssignee.push(item.json.html_url)
  }
}
 
return { open, noAssignee }

Mattermostノードを追加してオープン中のIssueおよび担当者がアサインされていないIssueを通知します。

ノードを追加して設定パネルで以下のように設定します。

NameValue
Credential to connect withMattermostで発行したアクセストークンとMattermostのURLを設定
ResourceMessage
OperationPost
Channel Name or ID投稿したいチャネルのIDを設定
Message下記のメッセージを入力
Currently open issues are as follows:
{{ $json.open.map(i => '- ' + i).join('\n') }}

Issues with no assignees in the above list are as follows:
{{ $json.noAssignee.map(i => '- ' + i).join('\n') }}

これでIssue通知のワークフローは完成です。 最後にIssue登録ワークフローからIssue通知のワークフローを実行するようにします。

Workflowノードを追加して設定パネルで以下のように設定します。

NameValue
SourceDatabase
Workflow IDIssue通知ワークフローのID

動作確認

ワークフローの全体像は以下のようになります。

🔀 Issue登録

🔀 オープン中Issue通知

では、GitHubにIssueを登録してみます。リアクションを付けるとワークフロエンジンンにIssue登録イベントが送信され、Issue登録後にIssue同期ジョブが起動します。しばらく待つとオープン中のIssueが通知されます。

GitHubを確認するとIssueが登録されていることが確認できます。

n8nを確認するとワークフローが正常に完了していることが確認できます。