見出し画像

第6章(応用編):AIとJira APIで過去チケット検索を自動化する方法

■はじめに

応用編では、AIとAPIを連携させた業務自動化を扱います。

第1章(応用編):AIとAPIで問い合わせメールを管理表へ自動登録する方法

まだ第1章を読んでいない方は、先に第1章で全体像を確認してから本章を読むと理解しやすくなります。
本章は前章までの続きです。
第2章では、Gmail APIで問い合わせメールを取得し、AIで問い合わせ内容を分類する処理を作成しました。
第3章では、そのAI分類結果をGoogle Sheets APIで管理表へ自動登録する処理を作成しました。
第4章では、管理表の内容をもとにSlack APIで担当者通知を送る処理を作成しました。
第5章では、Google Drive APIで議事録や問い合わせ資料を保存する処理を作成しました。
まだ第1章から第5章を読んでいない方は、先に確認してから本章を読むと、処理のつながりが分かりやすくなります。

第1章(応用編):AIとAPIで問い合わせメールを管理表へ自動登録する方法
第2章(応用編):AIとGoogle Sheets APIで問い合わせ管理表を作る方法
第3章(応用編):AIとGoogle Sheets APIで管理表を自動作成する方法
第4章(応用編):AIとSlack APIで担当者通知を自動化する方法
第5章(応用編):AIとGoogle Drive APIで議事録・資料整理を自動化する方法

この章では、Jira APIを使って過去チケット検索を自動化する方法を作ります。
問い合わせ対応では、過去に似た事象があったかどうかを確認する場面があります。
・過去に同じエラーが出ていないか
・似た問い合わせがどのように解決されたか
・どの担当者が対応していたか
・過去チケットの回答文を参考にできるか
・既知問題なのか新規問題なのか
・調査済みの内容を再利用できるか
この確認を毎回人がJiraで検索していると時間がかかります。
Jira APIを使うと、問い合わせ内容をもとに過去チケットを検索できます。
さらにAIを組み合わせると、問い合わせ内容から検索キーワードを作成し、Jira APIで候補チケットを取得し、AIで類似度や参考ポイントを整理できます。
この章では、AIとJira APIを使って、問い合わせ内容に近い過去チケットを自動検索する仕組みを作ります。

■この章で作るもの

この章で作る処理は下記です。

Google Sheetsの問い合わせ管理表から対象問い合わせを取得
→ AIでJira検索用キーワードを作成
→ Jira APIで過去チケットを検索
→ 検索結果から件名、ステータス、URL、概要を取得
→ AIで参考になりそうなチケットを整理
→ Google SheetsへJira検索結果を反映
→ 必要に応じてSlackへ通知

この章では、問い合わせ内容から過去チケットを探すところまでを作ります。
第7章では、検索した過去チケットやFAQデータを使って、回答案作成へ広げます。

■Jira APIを使う理由

Jiraは、問い合わせ、障害、開発タスク、調査チケットなどを管理するために使われることが多いツールです。
Jiraに過去チケットが蓄積されている場合、それは重要なナレッジになります。
ただし、人が毎回手動で検索するのは面倒です。
Jira APIを使うと、プログラムから下記の操作ができます。

・JQLでチケットを検索する
・チケットの件名を取得する
・ステータスを取得する
・作成日時や更新日時を取得する
・説明文やコメントを取得する
・チケットURLを作成する
・検索結果をGoogle Sheetsへ保存する

AIは、問い合わせ内容から検索キーワードを作ります。
Jira APIは、そのキーワードを使って過去チケットを検索します。
Google Sheets APIは、検索結果を管理表へ反映します。
役割は下記です。

AI:問い合わせ内容から検索キーワードを作る
Jira API:過去チケットを検索する
Google Sheets API:検索結果を管理表へ反映する
Slack API:必要に応じて検索結果を通知する
Google Apps Script:AI API、Jira API、Sheets API、Slack APIをつなぐ

この役割分担にすると、問い合わせ対応時の過去事例探しをかなり楽にできます。

■過去チケット検索で見る項目

この章では、Jiraから下記の情報を取得します。

・チケットキー
・件名
・ステータス
・担当者
・作成日時
・更新日時
・概要
・チケットURL

最初からコメント全文まで取得する必要はありません。
まずは検索結果の候補を出し、参考になりそうなチケットを見つけるところから始めます。
チケット本文やコメントまで自動取得する場合は、情報量が多くなります。
最初は、件名、概要、ステータス、URLを取得し、人が確認しやすい形にする方が扱いやすいです。

■検索キーワードをAIに作らせる

Jira検索で難しいのは、検索キーワードの作り方です。
問い合わせ本文をそのまま検索しても、うまくヒットしないことがあります。
たとえば、問い合わせ本文が下記だとします。

ログイン時にエラーが表示され、管理画面に入れません。
昨日までは問題ありませんでした。

このまま検索するより、AIに下記のような検索キーワードを作らせる方が探しやすくなります。

ログイン エラー 管理画面

さらに技術系の問い合わせなら、エラーコード、機能名、製品名、画面名、発生条件などを抽出できます。
AIで検索キーワードを作ることで、Jira検索の精度を上げやすくなります。

■ここから解説する内容

・Jira APIを使うための準備
・Jira Base URLを保存する方法
・Jiraユーザー名とAPIトークンを安全に保存する方法
・AIでJira検索キーワードを作成するプロンプト
・Jira API用の認証ヘッダーを作成するコード
・JQLを作成するコード
・Jira APIで過去チケットを検索するコード
・Jira検索結果を扱いやすい形に整えるコード
・AIで参考チケットを整理するコード
・Google Sheets管理表へJira検索結果を反映するコード
・Slackへ検索結果を通知するコード
・第2章から第6章までをつなげる完成コード
・テスト用コード
・運用時の注意点

■Jira APIを使うための準備

ここからは、実際にJira APIを使う準備に入ります。
Jira APIを使うには、利用しているJira環境のURL、ユーザー名、APIトークンが必要です。
この章では、Google Apps ScriptからJira APIを呼び出します。
準備するものは下記です。

・JiraのBase URL
・Jiraにログインするメールアドレスまたはユーザー名
・Jira APIトークン
・検索対象のプロジェクトキー

JiraのBase URLは、下記のような形式です。

https://example.atlassian.net

オンプレミス版や社内Jiraの場合は、会社のJira URLになります。
APIトークンの取得方法や認証方式は環境によって異なります。
この章では、Google Apps ScriptからBasic認証形式で呼び出す形を使います。

■Jira設定を保存する

Jiraの設定情報は、コードに直接書きません。
Google Apps Scriptのスクリプトプロパティに保存します。

function setJiraConfig() {
  PropertiesService.getScriptProperties().setProperties({
    JIRA_BASE_URL: '<ここにJiraのBase URLを入れる>',
    JIRA_USER_EMAIL: '<ここにJiraユーザーのメールアドレスを入れる>',
    JIRA_API_TOKEN: '<ここにJira APIトークンを入れる>',
    JIRA_PROJECT_KEY: '<ここに検索対象のプロジェクトキーを入れる>'
  });
}

この関数は初期設定のときだけ実行します。
実行後は、コード内にトークンを直接書かず、スクリプトプロパティから呼び出します。

■設定情報を取得する関数

第5章までで使っている getConfig() に、Jira関連の設定を追加します。

function getConfig() {
  return {
    aiApiKey: PropertiesService.getScriptProperties().getProperty('AI_API_KEY'),
    sheetId: PropertiesService.getScriptProperties().getProperty('SHEET_ID'),
    slackBotToken: PropertiesService.getScriptProperties().getProperty('SLACK_BOT_TOKEN'),
    slackChannelId: PropertiesService.getScriptProperties().getProperty('SLACK_CHANNEL_ID'),
    driveRootFolderId: PropertiesService.getScriptProperties().getProperty('DRIVE_ROOT_FOLDER_ID'),
    jiraBaseUrl: PropertiesService.getScriptProperties().getProperty('JIRA_BASE_URL'),
    jiraUserEmail: PropertiesService.getScriptProperties().getProperty('JIRA_USER_EMAIL'),
    jiraApiToken: PropertiesService.getScriptProperties().getProperty('JIRA_API_TOKEN'),
    jiraProjectKey: PropertiesService.getScriptProperties().getProperty('JIRA_PROJECT_KEY')
  };
}

この形にしておくと、AI API、Google Sheets API、Slack API、Google Drive API、Jira APIの設定をまとめて管理できます。

■Jira APIの認証ヘッダーを作る

Jira APIへアクセスするため、認証ヘッダーを作成します。

function getJiraAuthHeaders() {
  const config = getConfig();
  const token = Utilities.base64Encode(config.jiraUserEmail + ':' + config.jiraApiToken);
  return {
    Authorization: 'Basic ' + token,
    Accept: 'application/json',
    'Content-Type': 'application/json'
  };
}

ユーザー名とAPIトークンをBase64エンコードし、Authorizationヘッダーに設定します。
APIトークンは重要情報なので、ログに出さないようにします。

■Jira検索キーワードをAIに作らせる

問い合わせ内容をもとに、Jira検索用キーワードをAIに作らせます。
まず、プロンプトを作成します。

function buildJiraSearchKeywordPrompt(inquiry) {
  return `
以下の問い合わせ内容をもとに、Jiraで過去チケットを検索するためのキーワードを作成してください。
【件名】
${inquiry.subject || ''}
【概要】
${inquiry.summary || ''}
【本文】
${inquiry.body || ''}
【分類】
${inquiry.category || ''}
【条件】
・検索に使いやすい短いキーワードにする
・重要な製品名、機能名、エラー内容、画面名があれば含める
・不要なあいさつや文章は含めない
・日本語でよい
・単語をスペース区切りで出力する
・キーワードのみを出力する
`;
}

AIには、Jira検索に使いやすい短いキーワードだけを出力させます。

■AIでJira検索キーワードを作成する

第2章で作成した callAiApi() を使って、検索キーワードを作成します。

function generateJiraSearchKeywordsByAi(inquiry) {
  try {
    const prompt = buildJiraSearchKeywordPrompt(inquiry);
    const aiText = callAiApi(prompt);
    return sanitizeJiraSearchText(aiText);
  } catch (error) {
    logError('generateJiraSearchKeywordsByAi', error);
    return sanitizeJiraSearchText(`${inquiry.subject || ''} ${inquiry.summary || ''}`);
  }
}

AIでキーワード作成に失敗した場合は、件名と概要を使って検索します。
処理が止まらないようにすることが大事です。

■Jira検索文字列を整える

AIが作った検索キーワードに、改行や余計な記号が含まれることがあります。
Jira検索に使いやすい形へ整えます。

function sanitizeJiraSearchText(text) {
  if (!text) {
    return '';
  }
  return text
    .replace(/[\r\n]+/g, ' ')
    .replace(/["']/g, '')
    .replace(/\s+/g, ' ')
    .trim();
}

この関数で、JQLに入れやすい検索文字列に整えます。

■JQLを作成する

Jira APIでは、JQLを使ってチケットを検索します。
この章では、対象プロジェクト内で、件名や説明文にキーワードを含むチケットを検索します。

function buildJiraJql(searchText) {
  const config = getConfig();
  const projectKey = config.jiraProjectKey;
  const escapedText = searchText.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
  return `project = ${projectKey} AND text ~ "${escapedText}" ORDER BY updated DESC`;
}

このJQLでは、指定プロジェクト内でテキスト検索を行い、更新日時が新しい順に並べます。
最初は広めに検索し、必要に応じて条件を絞ります。

■Jira APIでチケットを検索する

Jira APIの検索エンドポイントを呼び出します。

function searchJiraIssuesByJql(jql, maxResults) {
  const config = getConfig();
  const url = config.jiraBaseUrl + '/rest/api/3/search';
  const payload = {
    jql: jql,
    maxResults: maxResults || 5,
    fields: [
      'summary',
      'status',
      'assignee',
      'created',
      'updated',
      'description'
    ]
  };
  const options = {
    method: 'post',
    contentType: 'application/json',
    headers: getJiraAuthHeaders(),
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  };
  const response = UrlFetchApp.fetch(url, options);
  const statusCode = response.getResponseCode();
  const responseText = response.getContentText();
  if (statusCode < 200 || statusCode >= 300) {
    throw new Error(`Jira API error: ${statusCode} ${responseText}`);
  }
  return JSON.parse(responseText);
}

この関数で、JQLに一致するJiraチケットを取得できます。
最初は最大5件程度にしておくと確認しやすいです。

■JiraチケットURLを作成する

検索結果に含まれるチケットキーから、JiraのURLを作ります。

function buildJiraIssueUrl(issueKey) {
  const config = getConfig();
  return config.jiraBaseUrl + '/browse/' + issueKey;
}

このURLをGoogle Sheetsへ登録しておけば、管理表から過去チケットをすぐ開けます。

■Jira検索結果を扱いやすい形に整える

Jira APIの検索結果を、後続処理で扱いやすい形に変換します。

function convertJiraIssue(issue) {
  const fields = issue.fields || {};
  return {
    key: issue.key,
    url: buildJiraIssueUrl(issue.key),
    summary: fields.summary || '',
    status: fields.status && fields.status.name ? fields.status.name : '',
    assignee: fields.assignee && fields.assignee.displayName ? fields.assignee.displayName : '',
    created: fields.created || '',
    updated: fields.updated || ''
  };
}

まずは、件名、ステータス、担当者、作成日時、更新日時、URLを扱います。
説明文やコメントは、必要になってから追加する方が扱いやすいです。

■問い合わせ1件からJiraチケットを検索する

問い合わせ内容をもとに検索キーワードを作り、Jira APIで過去チケットを検索します。

function searchJiraIssuesForInquiry(inquiry) {
  try {
    const keywords = generateJiraSearchKeywordsByAi(inquiry);
    const jql = buildJiraJql(keywords);
    const result = searchJiraIssuesByJql(jql, 5);
    const issues = (result.issues || []).map(issue => convertJiraIssue(issue));
    return {
      keywords: keywords,
      jql: jql,
      issues: issues
    };
  } catch (error) {
    logError('searchJiraIssuesForInquiry', error);
    return {
      keywords: '',
      jql: '',
      issues: [],
      error: error.message
    };
  }
}

この関数を使うと、問い合わせ1件に対して、過去チケット候補を取得できます。

■サンプル問い合わせでJira検索をテストする

まずは、サンプル問い合わせでJira検索をテストします。

function testSearchJiraIssuesForInquiry() {
  const sampleInquiry = {
    subject: 'ログインできない件について',
    summary: 'ログイン時のエラーにより管理画面へ入れない問い合わせ',
    body: 'ログイン時にエラーが表示され、管理画面に入れません。昨日までは問題ありませんでした。',
    category: '技術問い合わせ'
  };
  const result = searchJiraIssuesForInquiry(sampleInquiry);
  Logger.log(JSON.stringify(result, null, 2));
}

このテストで、AIが検索キーワードを作成し、Jira APIで過去チケットを検索できるか確認します。

■AIで参考チケットを整理する

Jira検索結果が複数ある場合、どれを見ればよいか分かりにくいことがあります。
AIに、参考になりそうなチケットを整理させます。

function buildJiraSearchSummaryPrompt(inquiry, searchResult) {
  return `
以下の問い合わせ内容とJira検索結果をもとに、参考になりそうな過去チケットを整理してください。
【問い合わせ件名】
${inquiry.subject || ''}
【問い合わせ概要】
${inquiry.summary || ''}
【問い合わせ本文】
${inquiry.body || ''}
【Jira検索キーワード】
${searchResult.keywords || ''}
【Jira検索結果】
${JSON.stringify(searchResult.issues || [], null, 2)}
【条件】
・参考になりそうなチケットを上から順に整理する
・なぜ参考になりそうかを短く書く
・完全一致ではなく類似候補として扱う
・該当が弱い場合はその旨を書く
・3件以内に絞る
`;
}

検索結果をそのまま並べるより、AIに参考ポイントを整理させると読みやすくなります。

■AIでJira検索結果の要約を作る

AI APIを呼び出して、Jira検索結果の要約を作ります。

function summarizeJiraSearchResultByAi(inquiry, searchResult) {
  try {
    if (!searchResult.issues || searchResult.issues.length === 0) {
      return '参考になりそうな過去チケットは見つかりませんでした。';
    }
    const prompt = buildJiraSearchSummaryPrompt(inquiry, searchResult);
    return callAiApi(prompt);
  } catch (error) {
    logError('summarizeJiraSearchResultByAi', error);
    return 'Jira検索結果の要約中にエラーが発生しました。';
  }
}

この関数で、検索結果を人が見やすい形へ整えられます。

■Jira検索結果を1行用データにする

Google Sheetsへ反映するため、検索結果を文字列にします。

function formatJiraIssuesForSheet(searchResult) {
  if (!searchResult.issues || searchResult.issues.length === 0) {
    return '';
  }
  return searchResult.issues.map(issue => {
    return `${issue.key} ${issue.summary} ${issue.url}`;
  }).join('\n');
}

チケットキー、件名、URLを1セルにまとめます。
詳細を見たい場合は、URLからJiraを開けます。

■管理表にJira列を追加する

第5章までの管理表に、Jira検索結果用の列を追加します。
追加する列は下記です。

Jira検索状況
Jira検索キーワード
Jira候補チケット
Jira検索要約
Jira検索日時

ヘッダー関数を更新します。

function getInquiryHeaders() {
  return [
    '受付日時',
    '送信者',
    '件名',
    '問い合わせ概要',
    '分類',
    '優先度',
    '担当候補',
    'ステータス',
    '分類理由',
    '対応メモ',
    'スレッドID',
    'メッセージID',
    '登録日時',
    'Slack通知状況',
    'Slack通知日時',
    'Drive保存状況',
    'Drive URL',
    'Drive保存日時',
    'Jira検索状況',
    'Jira検索キーワード',
    'Jira候補チケット',
    'Jira検索要約',
    'Jira検索日時'
  ];
}

これで、問い合わせごとにJira検索結果を管理表へ残せます。

■Jira関連ヘッダーを追加する

既存表に不足しているJira列を追加します。

function ensureJiraHeaders() {
  const config = getConfig();
  const sheetName = getInquirySheetName();
  const range = `${sheetName}!A1:W1`;
  const response = Sheets.Spreadsheets.Values.get(config.sheetId, range);
  const existingHeaders = response.values && response.values.length > 0 ? response.values[0] : [];
  const requiredHeaders = getInquiryHeaders();
  const updatedHeaders = existingHeaders.slice();
  requiredHeaders.forEach(header => {
    if (!updatedHeaders.includes(header)) {
      updatedHeaders.push(header);
    }
  });
  const updateRange = `${sheetName}!A1:${columnNumberToLetter(updatedHeaders.length)}1`;
  Sheets.Spreadsheets.Values.update(
    { values: [updatedHeaders] },
    config.sheetId,
    updateRange,
    { valueInputOption: 'RAW' }
  );
}

この関数を実行すると、第5章までの管理表を壊さずにJira用の列を追加できます。

■Jira列を含めて行データを読み取る

convertRowToInquiry() に、Jira関連項目を追加します。

function convertRowToInquiry(row, headers, rowNumber) {
  const map = getHeaderIndexMap(headers);
  return {
    rowNumber: rowNumber,
    receivedAt: row[map['受付日時']] || '',
    from: row[map['送信者']] || '',
    subject: row[map['件名']] || '',
    summary: row[map['問い合わせ概要']] || '',
    category: row[map['分類']] || '',
    priority: row[map['優先度']] || '',
    assignee: row[map['担当候補']] || '',
    status: row[map['ステータス']] || '',
    reason: row[map['分類理由']] || '',
    memo: row[map['対応メモ']] || '',
    threadId: row[map['スレッドID']] || '',
    messageId: row[map['メッセージID']] || '',
    registeredAt: row[map['登録日時']] || '',
    slackStatus: row[map['Slack通知状況']] || '',
    slackNotifiedAt: row[map['Slack通知日時']] || '',
    driveStatus: row[map['Drive保存状況']] || '',
    driveUrl: row[map['Drive URL']] || '',
    driveSavedAt: row[map['Drive保存日時']] || '',
    jiraStatus: row[map['Jira検索状況']] || '',
    jiraKeywords: row[map['Jira検索キーワード']] || '',
    jiraIssues: row[map['Jira候補チケット']] || '',
    jiraSummary: row[map['Jira検索要約']] || '',
    jiraSearchedAt: row[map['Jira検索日時']] || ''
  };
}

この形にすると、問い合わせごとのJira検索状況を判定できます。

■Jira検索対象を抽出する

まだJira検索していない問い合わせを抽出します。

function filterJiraSearchTargets(inquiries) {
  return inquiries.filter(inquiry => {
    return !inquiry.jiraStatus && inquiry.status !== '完了';
  });
}

まずは、完了していない問い合わせを対象にします。
運用に合わせて、高優先度だけ、技術問い合わせだけ、要確認だけに絞ることもできます。

■Jira検索結果を管理表へ反映する

Jira検索結果をGoogle Sheetsへ更新します。
Jira関連列は19列目から23列目です。

function updateJiraSearchStatus(rowNumber, searchResult, summaryText) {
  const config = getConfig();
  const sheetName = getInquirySheetName();
  const range = `${sheetName}!S${rowNumber}:W${rowNumber}`;
  const issuesText = formatJiraIssuesForSheet(searchResult);
  const status = searchResult.issues && searchResult.issues.length > 0 ? '検索済み' : '該当なし';
  const valueRange = {
    values: [
      [
        status,
        searchResult.keywords || '',
        issuesText,
        summaryText || '',
        new Date().toISOString()
      ]
    ]
  };
  Sheets.Spreadsheets.Values.update(valueRange, config.sheetId, range, {
    valueInputOption: 'USER_ENTERED'
  });
}

この関数を使うと、Jira検索結果を管理表へ保存できます。

■問い合わせ1件のJira検索を実行する

問い合わせ1件について、Jira検索、AI要約、管理表更新まで行います。

function searchJiraForInquiryAndUpdateSheet(inquiry) {
  try {
    const searchResult = searchJiraIssuesForInquiry(inquiry);
    const summaryText = summarizeJiraSearchResultByAi(inquiry, searchResult);
    updateJiraSearchStatus(inquiry.rowNumber, searchResult, summaryText);
    Logger.log(`Jira検索完了: row ${inquiry.rowNumber}`);
  } catch (error) {
    logError('searchJiraForInquiryAndUpdateSheet', error);
  }
}

検索に失敗した場合は、エラーをログに残します。
失敗時に検索済みにしないことで、後から再実行できます。

■Jira検索対象をまとめて処理する

管理表からJira検索対象を抽出し、1件ずつ検索します。

function searchJiraTargets() {
  ensureJiraHeaders();
  const inquiries = getInquiriesFromSheet();
  const targets = filterJiraSearchTargets(inquiries);
  if (!targets || targets.length === 0) {
    Logger.log('Jira検索対象はありません');
    return;
  }
  targets.forEach(inquiry => {
    searchJiraForInquiryAndUpdateSheet(inquiry);
  });
  Logger.log('Jira検索件数: ' + targets.length);
}

この関数を実行すると、未検索の問い合わせについて過去チケット検索が行われます。

■SlackへJira検索結果を通知する

必要に応じて、Jira検索結果をSlackへ通知できます。

function buildJiraSearchSlackMessage(inquiry) {
  return [
    'Jira過去チケット検索が完了しました。',
    '',
    `件名:${inquiry.subject || '件名なし'}`,
    `分類:${inquiry.category || '未分類'}`,
    `優先度:${inquiry.priority || '未設定'}`,
    '',
    `検索キーワード:${inquiry.jiraKeywords || '未設定'}`,
    '',
    `候補チケット:`,
    inquiry.jiraIssues || '候補なし',
    '',
    `検索要約:`,
    inquiry.jiraSummary || '要約なし'
  ].join('\n');
}

検索結果までSlackに流すと便利ですが、通知が多くなる場合があります。
最初はGoogle Sheetsへ記録し、必要なものだけSlack通知する方が扱いやすいです。

■Jira検索後の問い合わせを再取得する

Jira検索結果を管理表へ反映したあと、Slack通知する場合は、最新の行データを再取得します。

function notifyJiraSearchResultsToSlack() {
  const inquiries = getInquiriesFromSheet();
  const targets = inquiries.filter(inquiry => {
    return inquiry.jiraStatus === '検索済み' && inquiry.priority === '高';
  });
  targets.forEach(inquiry => {
    const message = buildJiraSearchSlackMessage(inquiry);
    postMessageToSlack(message);
  });
}

この例では、高優先度の問い合わせだけJira検索結果をSlack通知しています。
通知対象は運用に合わせて調整します。

■第2章から第6章までをつなげる

ここまでの流れをまとめると、下記になります。

Gmail APIで問い合わせメールを取得
→ AIで問い合わせ内容を分類
→ Google Sheets APIで管理表へ登録
→ Slack APIで担当者へ通知
→ Google Drive APIで関連メモや資料を保存
→ Jira APIで過去チケットを検索
→ Google Sheets APIでJira検索結果を管理表へ反映

この一連の流れを実行する関数を作ります。

function runInquiryManagementSlackDriveJiraFlow() {
  try {
    setupInquiryManagementSheet();
    const results = classifyInquiryEmails();
    saveNewClassifiedResultsToSheet(results);
    notifySlackTargets();
    saveDriveTargets();
    searchJiraTargets();
    Logger.log('問い合わせ管理、Slack通知、Drive保存、Jira検索が完了しました');
  } catch (error) {
    logError('runInquiryManagementSlackDriveJiraFlow', error);
  }
}

この関数を実行すると、問い合わせ取得から過去チケット検索までを一連の流れで処理できます。

■この章の完成コード

この章で追加する主要コードをまとめると下記です。

function setJiraConfig() {
  PropertiesService.getScriptProperties().setProperties({
    JIRA_BASE_URL: '<ここにJiraのBase URLを入れる>',
    JIRA_USER_EMAIL: '<ここにJiraユーザーのメールアドレスを入れる>',
    JIRA_API_TOKEN: '<ここにJira APIトークンを入れる>',
    JIRA_PROJECT_KEY: '<ここに検索対象のプロジェクトキーを入れる>'
  });
}
function getConfig() {
  return {
    aiApiKey: PropertiesService.getScriptProperties().getProperty('AI_API_KEY'),
    sheetId: PropertiesService.getScriptProperties().getProperty('SHEET_ID'),
    slackBotToken: PropertiesService.getScriptProperties().getProperty('SLACK_BOT_TOKEN'),
    slackChannelId: PropertiesService.getScriptProperties().getProperty('SLACK_CHANNEL_ID'),
    driveRootFolderId: PropertiesService.getScriptProperties().getProperty('DRIVE_ROOT_FOLDER_ID'),
    jiraBaseUrl: PropertiesService.getScriptProperties().getProperty('JIRA_BASE_URL'),
    jiraUserEmail: PropertiesService.getScriptProperties().getProperty('JIRA_USER_EMAIL'),
    jiraApiToken: PropertiesService.getScriptProperties().getProperty('JIRA_API_TOKEN'),
    jiraProjectKey: PropertiesService.getScriptProperties().getProperty('JIRA_PROJECT_KEY')
  };
}
function getJiraAuthHeaders() {
  const config = getConfig();
  const token = Utilities.base64Encode(config.jiraUserEmail + ':' + config.jiraApiToken);
  return {
    Authorization: 'Basic ' + token,
    Accept: 'application/json',
    'Content-Type': 'application/json'
  };
}
function buildJiraSearchKeywordPrompt(inquiry) {
  return `
以下の問い合わせ内容をもとに、Jiraで過去チケットを検索するためのキーワードを作成してください。
【件名】
${inquiry.subject || ''}
【概要】
${inquiry.summary || ''}
【本文】
${inquiry.body || ''}
【分類】
${inquiry.category || ''}
【条件】
・検索に使いやすい短いキーワードにする
・重要な製品名、機能名、エラー内容、画面名があれば含める
・不要なあいさつや文章は含めない
・日本語でよい
・単語をスペース区切りで出力する
・キーワードのみを出力する
`;
}
function sanitizeJiraSearchText(text) {
  if (!text) {
    return '';
  }
  return text
    .replace(/[\r\n]+/g, ' ')
    .replace(/["']/g, '')
    .replace(/\s+/g, ' ')
    .trim();
}
function generateJiraSearchKeywordsByAi(inquiry) {
  try {
    const prompt = buildJiraSearchKeywordPrompt(inquiry);
    const aiText = callAiApi(prompt);
    return sanitizeJiraSearchText(aiText);
  } catch (error) {
    logError('generateJiraSearchKeywordsByAi', error);
    return sanitizeJiraSearchText(`${inquiry.subject || ''} ${inquiry.summary || ''}`);
  }
}
function buildJiraJql(searchText) {
  const config = getConfig();
  const projectKey = config.jiraProjectKey;
  const escapedText = searchText.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
  return `project = ${projectKey} AND text ~ "${escapedText}" ORDER BY updated DESC`;
}
function searchJiraIssuesByJql(jql, maxResults) {
  const config = getConfig();
  const url = config.jiraBaseUrl + '/rest/api/3/search';
  const payload = {
    jql: jql,
    maxResults: maxResults || 5,
    fields: [
      'summary',
      'status',
      'assignee',
      'created',
      'updated',
      'description'
    ]
  };
  const options = {
    method: 'post',
    contentType: 'application/json',
    headers: getJiraAuthHeaders(),
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  };
  const response = UrlFetchApp.fetch(url, options);
  const statusCode = response.getResponseCode();
  const responseText = response.getContentText();
  if (statusCode < 200 || statusCode >= 300) {
    throw new Error(`Jira API error: ${statusCode} ${responseText}`);
  }
  return JSON.parse(responseText);
}
function buildJiraIssueUrl(issueKey) {
  const config = getConfig();
  return config.jiraBaseUrl + '/browse/' + issueKey;
}
function convertJiraIssue(issue) {
  const fields = issue.fields || {};
  return {
    key: issue.key,
    url: buildJiraIssueUrl(issue.key),
    summary: fields.summary || '',
    status: fields.status && fields.status.name ? fields.status.name : '',
    assignee: fields.assignee && fields.assignee.displayName ? fields.assignee.displayName : '',
    created: fields.created || '',
    updated: fields.updated || ''
  };
}
function searchJiraIssuesForInquiry(inquiry) {
  try {
    const keywords = generateJiraSearchKeywordsByAi(inquiry);
    const jql = buildJiraJql(keywords);
    const result = searchJiraIssuesByJql(jql, 5);
    const issues = (result.issues || []).map(issue => convertJiraIssue(issue));
    return {
      keywords: keywords,
      jql: jql,
      issues: issues
    };
  } catch (error) {
    logError('searchJiraIssuesForInquiry', error);
    return {
      keywords: '',
      jql: '',
      issues: [],
      error: error.message
    };
  }
}
function buildJiraSearchSummaryPrompt(inquiry, searchResult) {
  return `
以下の問い合わせ内容とJira検索結果をもとに、参考になりそうな過去チケットを整理してください。
【問い合わせ件名】
${inquiry.subject || ''}
【問い合わせ概要】
${inquiry.summary || ''}
【問い合わせ本文】
${inquiry.body || ''}
【Jira検索キーワード】
${searchResult.keywords || ''}
【Jira検索結果】
${JSON.stringify(searchResult.issues || [], null, 2)}
【条件】
・参考になりそうなチケットを上から順に整理する
・なぜ参考になりそうかを短く書く
・完全一致ではなく類似候補として扱う
・該当が弱い場合はその旨を書く
・3件以内に絞る
`;
}
function summarizeJiraSearchResultByAi(inquiry, searchResult) {
  try {
    if (!searchResult.issues || searchResult.issues.length === 0) {
      return '参考になりそうな過去チケットは見つかりませんでした。';
    }
    const prompt = buildJiraSearchSummaryPrompt(inquiry, searchResult);
    return callAiApi(prompt);
  } catch (error) {
    logError('summarizeJiraSearchResultByAi', error);
    return 'Jira検索結果の要約中にエラーが発生しました。';
  }
}
function formatJiraIssuesForSheet(searchResult) {
  if (!searchResult.issues || searchResult.issues.length === 0) {
    return '';
  }
  return searchResult.issues.map(issue => {
    return `${issue.key} ${issue.summary} ${issue.url}`;
  }).join('\n');
}
function getInquiryHeaders() {
  return [
    '受付日時',
    '送信者',
    '件名',
    '問い合わせ概要',
    '分類',
    '優先度',
    '担当候補',
    'ステータス',
    '分類理由',
    '対応メモ',
    'スレッドID',
    'メッセージID',
    '登録日時',
    'Slack通知状況',
    'Slack通知日時',
    'Drive保存状況',
    'Drive URL',
    'Drive保存日時',
    'Jira検索状況',
    'Jira検索キーワード',
    'Jira候補チケット',
    'Jira検索要約',
    'Jira検索日時'
  ];
}
function ensureJiraHeaders() {
  const config = getConfig();
  const sheetName = getInquirySheetName();
  const range = `${sheetName}!A1:W1`;
  const response = Sheets.Spreadsheets.Values.get(config.sheetId, range);
  const existingHeaders = response.values && response.values.length > 0 ? response.values[0] : [];
  const requiredHeaders = getInquiryHeaders();
  const updatedHeaders = existingHeaders.slice();
  requiredHeaders.forEach(header => {
    if (!updatedHeaders.includes(header)) {
      updatedHeaders.push(header);
    }
  });
  const updateRange = `${sheetName}!A1:${columnNumberToLetter(updatedHeaders.length)}1`;
  Sheets.Spreadsheets.Values.update(
    { values: [updatedHeaders] },
    config.sheetId,
    updateRange,
    { valueInputOption: 'RAW' }
  );
}
function convertRowToInquiry(row, headers, rowNumber) {
  const map = getHeaderIndexMap(headers);
  return {
    rowNumber: rowNumber,
    receivedAt: row[map['受付日時']] || '',
    from: row[map['送信者']] || '',
    subject: row[map['件名']] || '',
    summary: row[map['問い合わせ概要']] || '',
    category: row[map['分類']] || '',
    priority: row[map['優先度']] || '',
    assignee: row[map['担当候補']] || '',
    status: row[map['ステータス']] || '',
    reason: row[map['分類理由']] || '',
    memo: row[map['対応メモ']] || '',
    threadId: row[map['スレッドID']] || '',
    messageId: row[map['メッセージID']] || '',
    registeredAt: row[map['登録日時']] || '',
    slackStatus: row[map['Slack通知状況']] || '',
    slackNotifiedAt: row[map['Slack通知日時']] || '',
    driveStatus: row[map['Drive保存状況']] || '',
    driveUrl: row[map['Drive URL']] || '',
    driveSavedAt: row[map['Drive保存日時']] || '',
    jiraStatus: row[map['Jira検索状況']] || '',
    jiraKeywords: row[map['Jira検索キーワード']] || '',
    jiraIssues: row[map['Jira候補チケット']] || '',
    jiraSummary: row[map['Jira検索要約']] || '',
    jiraSearchedAt: row[map['Jira検索日時']] || ''
  };
}
function filterJiraSearchTargets(inquiries) {
  return inquiries.filter(inquiry => {
    return !inquiry.jiraStatus && inquiry.status !== '完了';
  });
}
function updateJiraSearchStatus(rowNumber, searchResult, summaryText) {
  const config = getConfig();
  const sheetName = getInquirySheetName();
  const range = `${sheetName}!S${rowNumber}:W${rowNumber}`;
  const issuesText = formatJiraIssuesForSheet(searchResult);
  const status = searchResult.issues && searchResult.issues.length > 0 ? '検索済み' : '該当なし';
  const valueRange = {
    values: [
      [
        status,
        searchResult.keywords || '',
        issuesText,
        summaryText || '',
        new Date().toISOString()
      ]
    ]
  };
  Sheets.Spreadsheets.Values.update(valueRange, config.sheetId, range, {
    valueInputOption: 'USER_ENTERED'
  });
}
function searchJiraForInquiryAndUpdateSheet(inquiry) {
  try {
    const searchResult = searchJiraIssuesForInquiry(inquiry);
    const summaryText = summarizeJiraSearchResultByAi(inquiry, searchResult);
    updateJiraSearchStatus(inquiry.rowNumber, searchResult, summaryText);
    Logger.log(`Jira検索完了: row ${inquiry.rowNumber}`);
  } catch (error) {
    logError('searchJiraForInquiryAndUpdateSheet', error);
  }
}
function searchJiraTargets() {
  ensureJiraHeaders();
  const inquiries = getInquiriesFromSheet();
  const targets = filterJiraSearchTargets(inquiries);
  if (!targets || targets.length === 0) {
    Logger.log('Jira検索対象はありません');
    return;
  }
  targets.forEach(inquiry => {
    searchJiraForInquiryAndUpdateSheet(inquiry);
  });
  Logger.log('Jira検索件数: ' + targets.length);
}
function buildJiraSearchSlackMessage(inquiry) {
  return [
    'Jira過去チケット検索が完了しました。',
    '',
    `件名:${inquiry.subject || '件名なし'}`,
    `分類:${inquiry.category || '未分類'}`,
    `優先度:${inquiry.priority || '未設定'}`,
    '',
    `検索キーワード:${inquiry.jiraKeywords || '未設定'}`,
    '',
    `候補チケット:`,
    inquiry.jiraIssues || '候補なし',
    '',
    `検索要約:`,
    inquiry.jiraSummary || '要約なし'
  ].join('\n');
}
function notifyJiraSearchResultsToSlack() {
  const inquiries = getInquiriesFromSheet();
  const targets = inquiries.filter(inquiry => {
    return inquiry.jiraStatus === '検索済み' && inquiry.priority === '高';
  });
  targets.forEach(inquiry => {
    const message = buildJiraSearchSlackMessage(inquiry);
    postMessageToSlack(message);
  });
}
function runInquiryManagementSlackDriveJiraFlow() {
  try {
    setupInquiryManagementSheet();
    const results = classifyInquiryEmails();
    saveNewClassifiedResultsToSheet(results);
    notifySlackTargets();
    saveDriveTargets();
    searchJiraTargets();
    Logger.log('問い合わせ管理、Slack通知、Drive保存、Jira検索が完了しました');
  } catch (error) {
    logError('runInquiryManagementSlackDriveJiraFlow', error);
  }
}
function testSearchJiraIssuesForInquiry() {
  const sampleInquiry = {
    subject: 'ログインできない件について',
    summary: 'ログイン時のエラーにより管理画面へ入れない問い合わせ',
    body: 'ログイン時にエラーが表示され、管理画面に入れません。昨日までは問題ありませんでした。',
    category: '技術問い合わせ'
  };
  const result = searchJiraIssuesForInquiry(sampleInquiry);
  Logger.log(JSON.stringify(result, null, 2));
}

このコードを第2章から第5章までのコードに追加すると、問い合わせ内容をもとにJira APIで過去チケットを検索できます。

■運用時に注意すること

Jira APIで過去チケット検索を自動化する場合は、下記に注意します。

・APIトークンをコードに直接書かない
・検索対象プロジェクトを絞る
・最初は検索件数を少なくする
・検索結果を完全一致と決めつけない
・過去チケットの内容をそのまま回答に使わない
・機密情報をSlackへ流しすぎない
・検索結果が多すぎる場合はJQLを調整する
・チケットコメント全文の取得は必要になってから追加する

過去チケットは便利ですが、必ず現在の問い合わせにそのまま使えるとは限りません。
AIには参考候補を整理させ、人が最終的に判断する形が使いやすいです。

■まとめ

Jira APIを使うと、問い合わせ内容に近い過去チケットを自動検索できます。
AIで検索キーワードを作成し、Jira APIでチケットを検索し、検索結果をGoogle Sheetsへ反映できます。
さらにAIで検索結果を整理すれば、どの過去チケットが参考になりそうかを確認しやすくなります。
この仕組みを作ると、過去事例探しの時間を減らせます。
同じような問い合わせや障害が過去にあった場合、調査の初動を早められます。
この章では、AIとJira APIで過去チケット検索を自動化する方法を作りました。
次章では、過去チケットやFAQデータを活用して、問い合わせ回答案を自動作成する方法へ進みます。

■次回

第7章(応用編):AIとFAQデータを連携して回答案を自動生成する方法


いいなと思ったら応援しよう!