Creating articles automatically from ticket comments with DevRev Snap-in

Creating articles automatically from ticket comments with DevRev Snap-in

I built a mechanism using DevRev's Snap-in that automatically creates Articles by retrieving content from URLs in comments, triggered by Ticket stage changes. This presentation covers everything from development environment setup to deployment and operational improvements.
2026.04.24

This page has been translated by machine translation. View original

Introduction

I built a mechanism with DevRev's Snap-in that "manages blog post writing with Tickets, and when you finish writing an article and mark the Ticket as Resolved, the content is automatically imported into DevRev's Article (knowledge base)." The purpose is to automate operations so that knowledge accumulates in DevRev as you progress through tasks.

Snap-in is a developer extension that allows you to execute TypeScript functions on DevRev. Unlike GUI-based Workflows, it enables more flexible processing such as external HTTP requests. I initially attempted to build this with Workflows but abandoned it due to constraints. The details of that process are summarized in a separate article.

Target Audience

  • Engineers interested in automation using DevRev's Snap-in
  • Those considering integration between Tickets and Articles in DevRev
  • Those who want to learn about the Snap-in development flow (init → implementation → deployment → activation)

References

Overview of the Completed Workflow

When you comment a blog URL in the Ticket's customer messages and change the stage to Resolved, the article content is automatically registered as an Article.

Setting Up the Development Environment

Authenticate with DevRev CLI and generate a template.

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

The structure of the generated project is as follows:

Project Structure
devrev-snaps-typescript-template/
├── manifest.yaml                          # Event sources and function definitions
└── code/
    ├── package.json
    ├── tsconfig.json
    └── src/
        ├── function-factory.ts            # Function registration
        ├── index.ts
        └── functions/
            └── on_work_creation/          # Template function (to be replaced)
                └── index.ts

The template includes a sample function that responds to the work_created event. We'll change this to work_updated and proceed with implementation.

Manifest Configuration

In the manifest, we define three components: event sources, functions, and automations. We subscribe to the work_updated event in event_sources and link events to functions in automations. Stage determination is done in the function code.

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

Function Implementation (Initial Version: Extract URL from Description)

In the initial implementation, I extracted the URL from the Ticket's Description using a regular expression and detected stage changes from the work_updated event payload. The service account token is automatically passed through event.context.secrets.service_account_token, so there's no need to manage authentication information separately.

on_ticket_resolved/index.ts (Excerpt from initial version)
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;

  // Process only when Ticket transitions from 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 || "");
  // ... Fetch content from URL and create Article
}

This initial version worked correctly for the workflow.

Specification Change: Get URL from Customer Messages

After running the initial version, a team member pointed out that "Getting the URL from customer messages rather than the Description might be more practical for operations."

Indeed, the Description is written when creating a Ticket. Adding a blog URL later is unnatural and mixes with other content. It's more natural to share the URL in a comment and then mark it as Resolved. Since comments may contain multiple URLs, we decided to consider the last posted one as authoritative.

Also, regarding the referenced URLs, we needed to make a modification since we're specifically targeting DevelopersIO in this case.

The changes can be summarized as follows:

  1. Target Domain Restriction: Changed to match only dev.classmethod.jp
  2. Visibility Filter: Specified Public and External to retrieve only customer messages, excluding internal discussions and events
  3. Pagination: Used cursor to scan all records and return the last found URL
on_ticket_resolved/index.ts (Main parts after specification change)
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;
}

In case of errors, we notify with a comment on the Ticket and skip processing. This handles failures in timeline retrieval, URL not found, and content retrieval failures.

HTML Parsing and Article Creation

Extract the contents of the <title> and <article> tags (or <body> if not found) from the page at the URL, and create an Article with createArticle. By setting resource.url to the original URL, we maintain a reference from the Article to the original post.

HTML Parsing and Article Creation
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 };
}

// Create 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);

Deployment and Verification

Deployment consists of 3 steps:

# 1. Create package (first time only)
devrev snap_in_package create-one --slug ticket-to-article

# 2. Create version (build and upload happen automatically)
devrev snap_in_version create-one --path . --package <package_id> -w 5

# 3. Create draft and activate
devrev snap_in draft --snap_in_version <version_id>
devrev snap_in activate <snap_in_id>

To verify operation, create a Ticket, post a DevelopersIO article URL in the comments, and change the stage from Work in Progress to Resolved. A few seconds later, the following comment was automatically posted to the Ticket:

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

devrev bot message

Conclusion

Initially, I designed the system to extract URLs from the Description, but based on team member feedback, I changed it to retrieve from customer messages. Sharing URLs via comments and then marking as Resolved is a more natural operational flow and doesn't clutter the Description.

Snap-ins are suitable for cases requiring external HTTP requests or complex data processing. For workflows that can be completed solely with DevRev object operations, GUI-based Workflows are more appropriate, and it's best to choose based on your needs.

Share this article