Backlog Wiki のチェンジログっぽいものを作成する(Lambda編)

Backlog Wiki のチェンジログっぽいものを作成する(Lambda編)

Clock Icon2025.05.14

はじめに

アノテーション株式会社のあのふじたです。
最近、BacklogのWikiの更新日時が古いページの確認をする機会があり検索に苦しみました。
付け焼き刃的なものですが Backlog Wiki のチェンジログっぽいものをShellで作成したことをブログ化しました。
ついでにLambda化して自動実行させれば良いのでは...と思いこちらも書いていきます。
基本的にAIに壁打ちしてもらいつつShellからPythonへ書き換えたコードで動作確認しています。

Lambda 全体の流れ

  1. SSMパラメータストアに必要な情報を設定
  2. Lambdaの作成
  3. 一般設定でタイムアウト値を延ばしておく
  4. Lambdaの実行ロールにSSMへのアクセス権限を追加
  5. 環境変数の設定
  6. testして確認

事前準備

Backlog APIキー 取得

APIキーを取得しておきましょう。 Backlog の [個人設定 > API] から取得できます。

backlog-apikey-gen

チェンジログページの作成

対象の Backlog の Wiki にページを作成しておきます。
中身は空でOKです。
ページ一覧で一番上にソートされるように一旦 00.Changelog として作成しました。
また作成後のページID 1234567890は後で利用するのでメモしておきます。
XXX.backlog.jp/alias/wiki/1234567890

AWS Systems Manager(SSM)パラメータストアの設定

SSMパラメータストアに

  • Backlog の APIKEY のユーザ名 (Lambdaで利用しませんが、KEY所有者の証跡として)
  • Backlog の APIKEY
  • WIKI一覧を取得するスペースID( 例 XXX.backlog.jp であれば XXX)
  • WIKI一覧を取得するプロジェクトID( 例 XXX.backlog.jp/view/<PROJECT_ID>-001<PROJECT_ID> )
  • 更新するWIKI ID(例:作成後のページID 1234567890)

をまとめて設定します。

名前は backlog_update_config とします

{
  "API_KEY_USER": "anofujita",
  "SOURCE_API_KEY": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "SOURCE_SPACE_ID": "XXX",
  "SOURCE_PROJECT_ID": "XXXXX",
  "TARGET_WIKI_ID": "1234567890"
}

Lambda の作成

AWSのマネジメントコンソールから Lambda > 関数 > 関数の作成

create-lambda-ss01

関数名は backlog_wiki_changelog_upload
ランタイムは Python 3.13
を選択

create-lambda-ss02

その他は特に変更なく作成します。

Lambdaのコードを以下に変更します。

backlog.py

import urllib.parse
import urllib.request
import json
import logging
from typing import Dict, Any, List

# ロガーの設定
logger = logging.getLogger()

def update_wiki_content(space_id: str, wiki_id: str, content: str, api_key: str) -> str:
    """指定した wiki を 指定した content に更新してレスポンスを返す

    Args:
        space_id (str): BacklogのスペースID
        wiki_id (str): 更新対象の wiki_id
        content (str): 更新内容
        api_key (str): Backlog APIキー

    Returns:
        str: レスポンス
    """
    logger.info(f"Updating wiki content for wiki_id: {wiki_id} in space: {space_id}")
    # URLとデータ、ヘッダーを生成する
    url = f"https://{space_id}.backlog.jp/api/v2/wikis/{wiki_id}?apiKey={api_key}"
    data = urllib.parse.urlencode({"content": content, "mailNotify": "false"}).encode('ascii')
    headers = {"Content-Type": "application/x-www-form-urlencoded"}

    # コンテンツの一部をログに出力(長すぎる場合は省略)
    content_preview = content[:100] + "..." if len(content) > 100 else content
    logger.info(f"Content to update (preview): {content_preview}")

    try:
        # urllib.requestを使用してPATCHリクエストを送信
        logger.info(f"Sending PATCH request to: {url}")
        req = urllib.request.Request(url, data, headers, method='PATCH')

        with urllib.request.urlopen(req) as r:
            response_text = r.read().decode()
            logger.info(f"Response status code: {r.status}")
            if r.status == 200:
                logger.info(f"Successfully updated wiki content. Response: {response_text[:100]}...")
                return response_text
            else:
                error_msg = f"Failed to update wiki: {r.status} {response_text}"
                logger.error(error_msg)
                raise Exception(error_msg)
    except urllib.error.HTTPError as e:
        error_msg = f"HTTP Error: {e.code} {e.reason}"
        logger.error(error_msg)
        logger.error(f"Response body: {e.read().decode()}")
        raise Exception(error_msg)
    except Exception as e:
        logger.error(f"Exception occurred while updating wiki: {str(e)}")
        raise

def get_wiki_list(space_id: str, project_id: str, api_key: str) -> List[Dict[str, Any]]:
    """指定したプロジェクトのWiki一覧を取得する

    Args:
        space_id (str): BacklogのスペースID
        project_id (str): プロジェクトID または プロジェクトキー
        api_key (str): Backlog APIキー

    Returns:
        List[Dict[str, Any]]: Wiki一覧情報
    """
    logger.info(f"Getting wiki list for project_id: {project_id} in space: {space_id}")
    url = f"https://{space_id}.backlog.jp/api/v2/wikis?apiKey={api_key}&projectIdOrKey={project_id}"

    try:
        logger.info(f"Sending GET request to: {url}")
        with urllib.request.urlopen(url) as r:
            response_text = r.read().decode()
            logger.info(f"Response status code: {r.status}")
            if r.status == 200:
                wiki_list = json.loads(response_text)
                logger.info(f"Successfully retrieved wiki list. Count: {len(wiki_list)}")
                return wiki_list
            else:
                error_msg = f"Failed to get wiki list: {r.status} {response_text}"
                logger.error(error_msg)
                raise Exception(error_msg)
    except urllib.error.HTTPError as e:
        error_msg = f"HTTP Error: {e.code} {e.reason}"
        logger.error(error_msg)
        logger.error(f"Response body: {e.read().decode()}")
        raise Exception(error_msg)
    except Exception as e:
        logger.error(f"Exception occurred while getting wiki list: {str(e)}")
        raise

def format_wiki_info(wiki_list: List[Dict[str, Any]], backlog_url: str) -> List[Dict[str, str]]:
    """Wiki情報を整形する

    Args:
        wiki_list (List[Dict[str, Any]]): Wiki一覧情報
        backlog_url (str): BacklogのURL

    Returns:
        List[Dict[str, str]]: 整形されたWiki情報
    """
    logger.info(f"Formatting wiki info for {len(wiki_list)} wikis")
    formatted_list = []
    try:
        for wiki in wiki_list:
            formatted_wiki = {
                "最終更新日": wiki["updated"].split("T")[0],
                "最終更新者": wiki["updatedUser"]["name"],
                "Wiki名": wiki["name"],
                "URL": f"https://{backlog_url}/alias/wiki/{wiki['id']}"
            }
            formatted_list.append(formatted_wiki)

        # 最終更新日でソート(降順)
        formatted_list.sort(key=lambda x: x["最終更新日"], reverse=True)
        logger.info(f"Successfully formatted wiki info. Count: {len(formatted_list)}")
        return formatted_list
    except Exception as e:
        logger.error(f"Exception occurred while formatting wiki info: {str(e)}")
        raise

def generate_markdown_table(wiki_info: List[Dict[str, str]]) -> str:
    """Wiki情報からマークダウンの表を生成する

    Args:
        wiki_info (List[Dict[str, str]]): 整形されたWiki情報

    Returns:
        str: マークダウン形式の表
    """
    logger.info(f"Generating markdown table for {len(wiki_info)} wiki entries")
    try:
        if not wiki_info:
            logger.info("No wiki pages found.")
            return "No wiki pages found."

        # ヘッダー行
        headers = list(wiki_info[0].keys())
        markdown = "| " + " | ".join(headers) + " |\n"
        markdown += "| " + " | ".join(["---" for _ in headers]) + " |\n"

        # データ行
        for wiki in wiki_info:
            row = []
            for header in headers:
                value = wiki[header]
                # パイプ文字をハイフンに置換
                if "|" in value:
                    value = value.replace("|", "-")
                    logger.info(f"Replaced pipe character in value: {value}")

                # アスタリスクやアンダースコアはダブルクォートでエスケープ
                if "*" in value or "_" in value:
                    value = f'"{value}"'

                row.append(value)
            markdown += "| " + " | ".join(row) + " |\n"

        logger.info(f"Successfully generated markdown table. Length: {len(markdown)} characters")
        # テーブルの一部をログに出力
        preview = markdown.split('\n')[:5]
        logger.info(f"Table preview (first few lines): \n{'\n'.join(preview)}")
        return markdown
    except Exception as e:
        logger.error(f"Exception occurred while generating markdown table: {str(e)}")
        raise

lambda_function.py

import os
import logging
import datetime
import backlog
import json
import boto3

# ロガーの設定
logger = logging.getLogger()
logger.setLevel(logging.INFO)

# SSMクライアントの初期化
ssm = boto3.client('ssm')

def get_config_from_parameter_store(param_name: str) -> dict:
    """SSMパラメータストアから設定を取得する

    Args:
        param_name (str): パラメータ名

    Returns:
        dict: 設定値の辞書
    """
    logger.info(f"Getting configuration from SSM parameter: {param_name}")
    try:
        response = ssm.get_parameter(
            Name=param_name,
            WithDecryption=True
        )
        param_value = response.get('Parameter', {}).get('Value', '{}')
        config = json.loads(param_value)
        logger.info(f"Successfully retrieved configuration. Keys: {list(config.keys())}")
        return config
    except Exception as e:
        logger.error(f"Failed to get configuration from SSM parameter: {param_name}, Error: {str(e)}")
        raise

def get_jst_datetime() -> str:
    """現在時刻をJST(日本時間)で取得する

    Returns:
        str: YYYY-MM-DD HH:MM:SS 形式の日本時間
    """
    # UTC時間を取得
    utc_now = datetime.datetime.utcnow()
    # JSTは UTC+9
    jst_now = utc_now + datetime.timedelta(hours=9)
    return jst_now.strftime("%Y-%m-%d %H:%M:%S")

def lambda_handler(event, context):
    logger.info("Starting Backlog Wiki update process")
    logger.info(f"Event: {json.dumps(event)}")

    # 環境変数からパラメータ名を取得
    source_param = os.environ.get('SOURCE_PARAM', '')

    logger.info(f"Getting configuration from parameter: {source_param}")

    try:
        # パラメータストアから設定を取得
        config = get_config_from_parameter_store(source_param)

        # 設定から必要な値を取得
        source_space_id = config.get('SOURCE_SPACE_ID', '')
        source_project_id = config.get('SOURCE_PROJECT_ID', '')
        source_api_key = config.get('SOURCE_API_KEY', '')
        target_wiki_id = config.get('TARGET_WIKI_ID', '')

        logger.info(f"Configuration: source_space_id={source_space_id}, source_project_id={source_project_id}")
        logger.info(f"Configuration: source_space_id={source_space_id}, target_wiki_id={target_wiki_id}")

        # Wiki一覧を取得
        logger.info(f"Getting wiki list from source Backlog")
        wiki_list = backlog.get_wiki_list(source_space_id, source_project_id, source_api_key)
        logger.info(f"Retrieved {len(wiki_list)} wiki pages")

        # Wiki情報を整形
        logger.info(f"Formatting wiki information")
        formatted_wiki_info = backlog.format_wiki_info(wiki_list, f"{source_space_id}.backlog.jp")

        # マークダウンテーブルを生成
        logger.info(f"Generating markdown table")
        markdown_table = backlog.generate_markdown_table(formatted_wiki_info)

        # ヘッダー情報を追加(日本時間で更新日時を表示)
        current_time_jst = get_jst_datetime()
        content = f"# {source_project_id} Wiki 更新ページ一覧\n\n"
        content += f"更新日時: {current_time_jst} (JST)\n\n"
        content += markdown_table

        logger.info(f"Final content length: {len(content)} characters")

        # Wikiページを更新
        logger.info(f"Updating target wiki page: {target_wiki_id}")
        response = backlog.update_wiki_content(
            source_space_id,
            target_wiki_id,
            content,
            source_api_key
        )

        logger.info(f"Wiki page updated successfully. Response preview: {response[:100]}...")

        return {
            'statusCode': 200,
            'body': 'Wiki page updated successfully'
        }

    except Exception as e:
        logger.error(f"Error updating wiki page: {str(e)}", exc_info=True)
        return {
            'statusCode': 500,
            'body': f'Error: {str(e)}'
        }

Lambdaの一般設定でタイムアウト値を延ばしておく

Lambdaの設定→一般設定→から編集しタイムアウトが3秒→1分程度に延ばしておきます。

lambda-timeout

Lambdaの実行ロールにSSMへのアクセス権限を追加

LambdaからAWS Systems Manager(SSM)パラメータストアへのアクセスが必要なので
Lambdaの設定→アクセス権限→実行ロールからロール編集しAmazonSSMReadOnlyAccess を付与しておきます。
以下のようになっていればOKです。

lambda-role-addpolicy

環境変数の設定

Lambdaの設定→環境変数→から編集
キー: SOURCE_PARAM
値: backlog_update_config
を設定しておきます。

lambda-env

testして確認

Lambdaのテストからテストイベントに何も手を加えずテストを実行します。
Wiki page updated successfully が出ていれば成功しています。

lambda-test-success

テスト成功後対象の Backlog に以下のようの表示でWikiページが更新されます。

  • PROJECT_ID Wiki 更新ページ一覧
  • 最終更新者
  • 最終更新日
  • Wiki名
  • URL

lambda-backlog-update-result-wiki

今回は EventBridge での定期的な更新等を想定しています。

最後に

今回はAI(Claude 3.7 Sonnet)に元になったShellからPythonへの変換をお願いしました。
色々な注文をつけつつもあっさり動いてしまい、AIの凄さを身に沁みている次第です。

アノテーション株式会社について

アノテーション株式会社はクラスメソッドグループのオペレーション専門特化企業です。サポート・運用・開発保守・情シス・バックオフィスの専門チームが、最新 IT テクノロジー、高い技術力、蓄積されたノウハウをフル活用し、お客様の課題解決を行っています。当社は様々な職種でメンバーを募集しています。「オペレーション・エクセレンス」と「らしく働く、らしく生きる」を共に実現するカルチャー・しくみ・働き方にご興味がある方は、アノテーション株式会社 採用サイトをぜひご覧ください。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.