DevRev Snap-in で Ticket のコメントから自動で Article を作成する

DevRev Snap-in で Ticket のコメントから自動で Article を作成する

DevRev の Snap-in を使って、Ticket のステージ変更をトリガーにコメント内の URL からコンテンツを取得し、Article を自動作成する仕組みを構築しました。開発環境のセットアップからデプロイ、運用上の改善までを紹介します。
2026.04.24

はじめに

「ブログ記事の執筆を Ticket で管理し、記事を書き終えたら Ticket を Resolved にする。それだけで記事の内容が DevRev の Article (ナレッジベース記事) に自動で取り込まれる。」そんな仕組みを DevRev の Snap-in で構築しました。 タスクを進めるだけで DevRev 側にもナレッジが蓄積されていく運用の自動化が目的です。

Snap-in は DevRev 上で TypeScript の関数を実行できる開発者向け拡張機能です。GUI ベースの Workflows と異なり、外部 HTTP リクエストなど自由度の高い処理を実装できます。なお、先に Workflows での構築を試みましたが HTTP リクエストのレスポンスサイズ上限により断念しました。その詳細な経緯は 別記事 にまとめています。

対象読者

  • DevRev の Snap-in を使った自動化に興味があるエンジニア
  • DevRev で Ticket と Article の連携を検討している方
  • Snap-in の開発フロー (init → 実装 → デプロイ → アクティベート) を知りたい方

参考

完成したワークフローの全体像

Ticket の customer messages にブログの URL をコメントし、ステージを Resolved にすると、記事の内容が Article として自動登録されます。

開発環境のセットアップ

DevRev CLI で認証し、テンプレートを生成します。

devrev profiles authenticate -o <org_slug> -u <email>
devrev snap_in_version init

生成されるプロジェクトの構成は以下の通りです。

プロジェクト構成
devrev-snaps-typescript-template/
├── manifest.yaml                          # イベントソースと関数の定義
└── code/
    ├── package.json
    ├── tsconfig.json
    └── src/
        ├── function-factory.ts            # 関数の登録
        ├── index.ts
        └── functions/
            └── on_work_creation/          # テンプレートの関数 (後で差し替え)
                └── index.ts

テンプレートには work_created イベントに応答するサンプル関数が含まれています。これを work_updated に変更して実装を進めます。

マニフェストの設定

マニフェストでは、イベントソース、関数、オートメーションの 3 つを定義します。event_sourceswork_updated イベントを購読し、automations でイベントと関数を紐付けます。ステージの判定は関数側のコードで行います。

manifest.yaml
manifest.yaml
version: "2"
name: "Ticket to Article"
description: "Ticket のステージが resolved に変更されたとき Article を作成する"

service_account:
  display_name: DevRev Bot

event_sources:
  organization:
    - name: devrev-event-source
      description: Ticket のステージ変更を監視するイベントソース
      display_name: Ticket stage change listener
      type: devrev-webhook
      config:
        event_types:
          - work_updated

functions:
  - name: on_ticket_resolved
    description: Ticket が resolved になったときに Article を作成する関数

automations:
  - name: handle-ticket-resolved
    source: devrev-event-source
    event_types:
      - work_updated
    function: on_ticket_resolved

関数の実装 (初版: Description から URL を抽出)

最初の実装では、Ticket の Description から URL を正規表現で抽出し、work_updated イベントのペイロードからステージ変更を検出する構成にしました。event.context.secrets.service_account_token でサービスアカウントのトークンが自動的に渡されるため、認証情報の管理は不要です。

on_ticket_resolved/index.ts (初版の抜粋)
on_ticket_resolved/index.ts
function extractUrl(text: string): string | null {
  const urlRegex = /https?:\/\/[^\s<>"{}|\\^`\[\]]+/g;
  const matches = text.match(urlRegex);
  return matches ? matches[0] : null;
}

export async function handleEvent(event: any) {
  const devrevPAT = event.context.secrets.service_account_token;
  const APIBase = event.execution_metadata.devrev_endpoint;
  const devrevSDK = client.setup({
    endpoint: APIBase,
    token: devrevPAT,
  });

  const payload = event.payload;
  const work = payload.work_updated.work;

  // Ticket かつ work_in_progress → resolved の遷移のみ処理
  if (work.type !== "ticket") return;
  if (work.stage?.name !== "resolved") return;
  if (payload.work_updated.old_work?.stage?.name !== "work_in_progress") return;

  const url = extractUrl(work.body || "");
  // ... URL 先のコンテンツを取得し Article を作成
}

この初版でもワークフロー自体は正常に動作しました。

仕様変更: customer messages から URL を取得する

初版を動かしてみたところ、チームメンバーから 「Description ではなく customer messages から URL を拾う方が運用しやすいのでは」 という指摘を受けました。

確かに、Description は Ticket 作成時に書くものです。後からブログ URL を追記するのは不自然ですし、他の内容と混在してしまいます。コメントで URL を共有してから Resolved にする方が、自然な運用フローになります。なおコメントには URL が複数含まれる可能性があるので、最後に投稿されたものを正とする方向で進めることになりました。

また、参照する URL についても、今回は DevelopersIO に限定する必要があったため、その修正を入れる必要がありました。

変更点をまとめると以下の通りです。

  1. 対象ドメインの限定: dev.classmethod.jp のみにマッチするよう変更
  2. visibility フィルター: PublicExternal を指定し、customer messages のみ取得。internal discussions や events は対象外
  3. ページネーション: cursor を使って全件走査し、最後に見つかった URL を返す
on_ticket_resolved/index.ts (仕様変更後の主要部分)
on_ticket_resolved/index.ts
function extractDevClassmethodUrl(text: string): string | null {
  const regex = /https?:\/\/dev\.classmethod\.jp\/[^\s<>"{}|\\^`\[\]]*/g;
  const matches = text.match(regex);
  return matches ? matches[matches.length - 1] : null;
}

async function findLastDevClassmethodUrl(
  devrevSDK: publicSDK.Api<unknown>,
  workId: string
): Promise<string | null> {
  let lastUrl: string | null = null;
  let cursor: string | undefined = undefined;

  do {
    const request: publicSDK.TimelineEntriesListRequest = {
      object: workId,
      limit: 50,
      visibility: [
        publicSDK.TimelineEntryVisibility.Public,
        publicSDK.TimelineEntryVisibility.External,
      ],
      ...(cursor ? { cursor, mode: publicSDK.ListMode.After } : {}),
    };

    const response = await devrevSDK.timelineEntriesListPost(request);
    const entries = response.data?.timeline_entries || [];

    for (const entry of entries) {
      const body = (entry as any).body;
      if (typeof body === "string") {
        const found = extractDevClassmethodUrl(body);
        if (found) {
          lastUrl = found;
        }
      }
    }

    cursor = response.data?.next_cursor || undefined;
  } while (cursor);

  return lastUrl;
}

エラー時は Ticket にコメントで通知し、処理をスキップします。タイムライン取得の失敗、URL が見つからない場合、コンテンツ取得の失敗のそれぞれに対応しています。

HTML パースと Article 作成

URL 先のページから <title><article> タグ (なければ <body>) の内容を抽出し、createArticle で Article を作成します。resource.url に元の URL を設定することで、Article から元記事への参照を保持します。

HTML パースと Article 作成
on_ticket_resolved/index.ts
function parseHtml(html: string): { title: string; body: string } {
  const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
  const title = titleMatch ? titleMatch[1].trim() : "Untitled";

  const articleMatch = html.match(/<article[^>]*>([\s\S]*?)<\/article>/i);
  const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);

  let rawBody = articleMatch ? articleMatch[1] : (bodyMatch ? bodyMatch[1] : "");

  const body = rawBody
    .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
    .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
    .replace(/<[^>]+>/g, "")
    .replace(/\s+/g, " ")
    .trim();

  return { title, body };
}

// Article 作成
const articleRequest: publicSDK.ArticlesCreateRequest = {
  title: title,
  description: body,
  owned_by: [work.owned_by?.[0]?.id || work.created_by?.id],
  resource: { url: url },
  status: publicSDK.ArticleStatus.Published,
};
const articleResponse = await devrevSDK.createArticle(articleRequest);

デプロイと動作確認

デプロイは以下の 3 ステップです。

# 1. パッケージ作成 (初回のみ)
devrev snap_in_package create-one --slug ticket-to-article

# 2. バージョン作成 (ビルド・アップロードが自動実行される)
devrev snap_in_version create-one --path . --package <package_id> -w 5

# 3. ドラフト作成とアクティベート
devrev snap_in draft --snap_in_version <version_id>
devrev snap_in activate <snap_in_id>

動作確認は、Ticket を作成してコメントに DevelopersIO の記事 URL を投稿し、ステージを Work in Progress → Resolved に変更します。数秒後、Ticket に以下のコメントが自動投稿されました。

Article を作成しました: ART-7 
タイトル: DevRev Workflows を最小構成で動かす | DevelopersIO 
URL: https://dev.classmethod.jp/articles/devrev-workflows-minimal-chatbot-flow/

devrev bot message

おわりに

当初は Description から URL を抽出する設計でしたが、チームメンバーからのフィードバックを受けて customer messages から取得する方式に変更しました。コメントで URL を共有してから Resolved にする方が自然な運用フローであり、Description を汚さずに済みます。

Snap-in は外部 HTTP リクエストや複雑なデータ加工が必要なケースに適しています。DevRev 内部のオブジェクト操作のみで完結するフローであれば GUI で構築できる Workflows が適しており、用途に応じて使い分けるのがよいでしょう。

この記事をシェアする

関連記事