何らかのコンテンツが更新されているか調べ、更新されていたらGitLabにgit commit ＆ pushするPythonプログラム

# この記事で行うこと

- 何らかのテキスト形式のコンテンツを時系列に追いかけ、前回と比較して更新されているか調べます。
- 更新されていたら、GitLab APIを使用してコンテンツを GitLabに git commit ＆ push します。
- Pythonで実装します。
- 更新を追いかけるコンテンツとして、本記事では [EtherCalc](https://ethercalc.net/) を使用します。

## 参考ページ（感謝します）

[GitLab Docs > API Docs > API resources](https://docs.gitlab.com/ee/api/api_resources.html)

## 環境

Ubuntu 20.04

```bash

$ python3 --version
Python 3.8.2
```

# GitLabの導入とプロジェクト作成

## GitLab導入

それでは作業を開始します。
gitサーバーとして、オンプレのGitLabを使用します。今回は手っ取り早くDockerでローカルに導入します。
端末で適当なディレクトリに移動後、以下を実行します。

```bash

git clone https://github.com/sameersbn/docker-gitlab

cd docker-gitlab

docker-compose up -d
```

コンテナ立ち上げ後にしばらく待って「docker-compose ps」を実行すると、以下のように表示されました。

```bash

           Name                         Command                  State                               Ports                        
----------------------------------------------------------------------------------------------------------------------------------
docker-gitlab_gitlab_1       /sbin/entrypoint.sh app:start    Up (healthy)   0.0.0.0:10022->22/tcp, 443/tcp, 0.0.0.0:10080->80/tcp
docker-gitlab_postgresql_1   /sbin/entrypoint.sh              Up             5432/tcp                                             
docker-gitlab_redis_1        docker-entrypoint.sh --log ...   Up             6379/tcp            
```

GitLabコンテナのhttpポートが10080ですので、ブラウザで http://localhost:10080/ にアクセスします。
下の画面が出たら、rootのパスワードを変更します。

<img width="668" alt="スクリーンショット 2019-09-03 15.46.59.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/fc1e516f-25cb-a53b-26f0-2d1312301722.png">

下の画面に行き、開発ユーザーを作成します（ユーザー名は何でも良いです）。

<img width="668" alt="スクリーンショット 2019-09-03 15.56.31.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/5a85840d-897e-503b-73c1-13d508d35c56.png">

## GitLabプロジェクトの作成

開発ユーザーでログインできたら、下の画面からCreate a projectを選択します。

![2020-08-17-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/0794fced-0971-60bb-cd58-fbeda38efbf6.png)

以下のように、プロジェクト名を「ethercalc_backup」としましたが、名前は何でも良いです。
Create projectボタンを押してプロジェクトを作成します。

![2020-08-17-02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/85eed65d-940d-a79b-0521-f442c46eca22.png)

以下のように、プロジェクトが作成されました。
プロジェクト名の下に Project ID が表示されています（本記事では3）。
このIDは後に、Pythonコードから使用します。

![2020-08-17-03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/354e6a2f-d2de-22fa-d0d5-538bfbef7737.png)

## GitLab API用のアクセストークン取得

後にPythonコードから GitLab API を使用します。
その時アクセストークンが必要になりますので取得しておきます。
以下のように、ブラウザの右上からプルダウンメニューを出し、「Settings」を選択します。

![2020-08-17-10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/b33eafed-c10c-f9f7-9a76-35f5c1fceede.png)

以下の画面で、左側のメニューから「Access Tokens」を選択します。

![2020-08-17-11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/d1972cd3-6b3a-48ab-7814-5159073b1fae.png)

以下の画面で、Nameに任意の名前を入力し、Scopesはapiにチェックを入れ、ボタン「Create Personal access token」を押します。

![2020-08-17-12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/1bda72e8-0d27-5700-83c1-4484c08bc938.png)

以下のようにアクセストークンが作成されました。クリップボードにコピーして、保存しておきます。本記事では以後、アクセストークンとして「6f8YXyrZ1SCSADHTJ2L9」を使用します。

![2020-08-17-13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/2e88ca07-abef-b576-633a-d941309167c7.png)

# EtherCalcの導入

テキスト形式のコンテンツ提供元として、本記事ではEtherCalcを使うことにします。GitLabと同様に、Dockerでローカルに導入します。
端末でGitLabとは別の適当なディレクトリに移動後、https://github.com/audreyt/ethercalc/blob/master/docker-compose.yml と同じ内容で docker-compose.yml を作成し、コンテナを立ち上げます。

```bash

wget https://raw.githubusercontent.com/audreyt/ethercalc/master/docker-compose.yml

docker-compose up -d
```

コンテナ立ち上げ後にしばらく待って「docker-compose ps」を実行すると、以下のように表示されました。

```bash

            Name                          Command               State          Ports        
--------------------------------------------------------------------------------------------
docker-ethercalc_ethercalc_1   sh -c REDIS_HOST=$REDIS_PO ...   Up      0.0.0.0:80->8000/tcp
docker-ethercalc_redis_1       docker-entrypoint.sh redis ...   Up      6379/tcp         
```

EtherCalcコンテナのhttpポートが80ですので、ブラウザで http://localhost/ にアクセスしてみます。

![2020-08-17-04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/608acb49-6935-c588-eed7-3949cf6dfb59.png)

テスト用にEtherCalcシートを2つ作っておきます。
テキストエディタを立ち上げ、foo.sc と bar.sc を新規作成して保存します。

```bash

editor foo.sc
```

```foo.sc

socialcalc:version:1.0
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary=SocialCalcSpreadsheetControlSave
--SocialCalcSpreadsheetControlSave
Content-type: text/plain; charset=UTF-8

# SocialCalc Spreadsheet Control Save
version:1.0
part:sheet
part:edit
part:audit
--SocialCalcSpreadsheetControlSave
Content-type: text/plain; charset=UTF-8

version:1.5
cell:A1:t:foo1
cell:A2:t:foo2
sheet:c:1:r:2:tvf:1
valueformat:1:text-wiki
--SocialCalcSpreadsheetControlSave
Content-type: text/plain; charset=UTF-8

version:1.0
rowpane:0:1:1
colpane:0:1:1
ecell:A1
--SocialCalcSpreadsheetControlSave
Content-type: text/plain; charset=UTF-8

--SocialCalcSpreadsheetControlSave--
```

```bash

editor bar.sc
```

```bar.sc

socialcalc:version:1.0
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary=SocialCalcSpreadsheetControlSave
--SocialCalcSpreadsheetControlSave
Content-type: text/plain; charset=UTF-8

# SocialCalc Spreadsheet Control Save
version:1.0
part:sheet
part:edit
part:audit
--SocialCalcSpreadsheetControlSave
Content-type: text/plain; charset=UTF-8

version:1.5
cell:A1:t:bar1
cell:A2:t:bar2
sheet:c:1:r:2:tvf:1
valueformat:1:text-wiki
--SocialCalcSpreadsheetControlSave
Content-type: text/plain; charset=UTF-8

version:1.0
rowpane:0:1:1
colpane:0:1:1
ecell:A1
--SocialCalcSpreadsheetControlSave
Content-type: text/plain; charset=UTF-8

--SocialCalcSpreadsheetControlSave--
```

上述の foo.sc と bar.sc は SocialCalc形式のテキストファイルで、EtherCalcにインポートできます。
SocialCalc形式でエキスポート／インポートすると、シートのフォーマット（見た目）もリカバリできるというメリットがあります。
CSV形式のファイルもインポートできますが、シートのフォーマットはリカバリできません。

以下を実行し、インポートします。

```bash

curl -X PUT -H 'Content-Type: text/x-socialcalc' --data-binary @foo.sc http://localhost/_/foo

curl -X PUT -H 'Content-Type: text/x-socialcalc' --data-binary @bar.sc http://localhost/_/bar
```

ブラウザで http://localhost/foo と http://localhost/bar にアクセスします。
シートのセルに以下のデータが入っていれば、インポート成功です。

![2020-08-17-05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/75db8691-0afa-5dc8-8ca0-cd19f5958146.png)

![2020-08-17-06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/2a8f10f5-cfd3-7bc7-3f23-d142c40c6a62.png)

これらの URL から SocialCalc形式および CSV形式でファイルをダウンロードし、GitLabで管理することにします。

# Pythonコード

コードが雑＆行儀が悪いのはお許しを。

論理的な処理手順は以下の通りです。
foo、barのコンテンツそれぞれについて、

- EtherCalcとGitLabからファイルをダウンロード（SocialCalc形式とcsv形式の両方）
- GitLabにファイルがなかったら、gitリポジトリに新規追加
- GitLabにファイルがあったら、EtherCalcとGitLabの両ファイルを比較し、差異があったら gitリポジトリを更新。
- GitLab APIを使用して、git commit & push
- その後、GitLab APIを使用して、git diffして結果をログに出力
- gitリポジトリの下に ethercalc というディレクトリを作成し、その下にバックアップします。

以下にPythonコードを記します。
コードで logger という変数を使っていますが、logging周りのコードは割愛しましたのでご了承ください。

```ethercalc_backup.py

import time
import datetime
import urllib.request
import urllib.parse
import json
import pprint
import re
import base64

# gitで管理するethercalcコンテンツのURL
ethercalc_uris = [ "http://localhost/foo", "http://localhost/bar" ]

# GitLab関連
gitlab_base_uri = "http://localhost:10080/"

# gitリポジトリ内でのバックアップ先
gitlab_backup_directory = "ethercalc"

gitlab_private_token = "6f8YXyrZ1SCSADHTJ2L9"
gitlab_project_id = 3

# 今
str_now = datetime.datetime.today().strftime("%Y%m%d_%H%M%S")

# 改行
LF = '\n'


def get_gitlab_file(private_token, file_path):
    """
    GitLabリポジトリから1ファイルを取得する

    Parameters
    ----------
    private_token : str
        GitLab API用アクセストークン

    file_path : str
        gitリポジトリのトップからのファイルパス

    Returns
    -------
    anonymous : json
        GitLabからのレスポンス
    """

    # https://docs.gitlab.com/ee/api/repository_files.html
    gitlab_uri = f"{gitlab_base_uri}api/v4/projects/{gitlab_project_id}/repository/files/{urllib.parse.quote(file_path, safe='')}?ref=master"
    logger.info(f"gitlab_uri={gitlab_uri}")
    headers = {
        "PRIVATE-TOKEN": private_token
    }
    request = urllib.request.Request(gitlab_uri, headers=headers)
    try:
        with urllib.request.urlopen(request) as res:
            res_files = json.loads(res.read())
    except urllib.error.HTTPError as ee:
        if ee.code == 404:
            return {}
        else:
            raise
    except:
        raise
    else:
        # logger.debug(f"gitlab res_commit={LF}{pprint.pformat(res_files)}")
        return res_files


def compare_ethercalc_and_gitlab(actions, ethercalc_uri, git_filename):
    """
    EtherCalcとGitLabリポジトリからファイルを取得して比較し、差異があればactions変数にactionを追加する

    Parameters
    ----------
    actions : list
        後に GitLabの commits APIに渡す actions変数

    ethercalc_uri : str
        EtherCalcのURI

    git_filename : str
        gitリポジトリでのファイル名

    Returns
    -------
    なし
    """

    logger.info(f"ethercalc URL={ethercalc_uri}")

    # EtherCalcからダウンロード
    request = urllib.request.Request(ethercalc_uri)
    with urllib.request.urlopen(request) as res:
        content_ethercalc = res.read().decode("utf-8")
    # logger.debug(f"content_ethercalc={LF}{content_ethercalc}")

    # GitLabからダウンロード
    action_str = ""
    file_path = f"{gitlab_backup_directory}/{git_filename}"
    res_gitlab_file = get_gitlab_file(gitlab_private_token, file_path)
    try:
        content_gitlab = base64.b64decode(res_gitlab_file["content"]).decode("utf-8")
    except KeyError:
        # GitLabにファイルがない時は、後に新規作成してgit commit＆push
        action_str = "create"
    except:
        raise
    else:
        # logger.debug(f"content_gitlab={LF}{content_gitlab}")

        # EtherCalcとGitLabからダウンロードしたファイルを比較
        if content_ethercalc == content_gitlab:
            logger.info("content_ethercalc == content_gitlab")
        else:
            logger.info("content_ethercalc != content_gitlab")
            # ファイル内容に差異がある時、後にgit commit＆push
            action_str = "update"

    # actionがcreateまたはupdateの時、actions変数に登録
    if 0 < len(action_str):
        action = {
            "action": action_str,
            "file_path": file_path,
            "content": content_ethercalc
        }
        actions.append(action)


def main():
    # ethercalc_urisの各URLを処理
    actions = list()
    count_commit = 0
    re_compile = re.compile(r".*/(.*?)$")
    for index, ethercalc_uri in enumerate(ethercalc_uris):
        basename, = re_compile.match(ethercalc_uri).groups()    # 文字列 "foo"、"bar" を取り出す
        socialcalc_uri = ethercalc_uri[::-1].replace(basename[::-1], basename[::-1] + "/_", 1)[::-1]
        csv_uri = ethercalc_uri + ".csv"
        logger.info(f"[{index}] {basename}")

        # SocialCalc形式でEtherCalcとGitLabからダウンロードして、ファイル内容比較
        time.sleep(0.5)     # DoS攻撃にならないように適当にsleep
        compare_ethercalc_and_gitlab(actions, socialcalc_uri, f"{basename}.sc")

        # csv形式でEtherCalcとGitLabからダウンロードして、ファイル内容比較
        time.sleep(0.5)     # DoS攻撃にならないように適当にsleep
        compare_ethercalc_and_gitlab(actions, csv_uri, f"{basename}.csv")

        if len(actions) == 0:
            # EtherCalcとGitLabのファイル内容に差異がなければ git commitしない
            continue

        # git commit ＆ push
        # https://docs.gitlab.com/ee/api/commits.html
        gitlab_uri = f"{gitlab_base_uri}api/v4/projects/{gitlab_project_id}/repository/commits"
        commit_message = datetime.datetime.today().strftime(f"backup {str_now} {basename}")
        logger.info(f'git commit -m "{commit_message}"')
        headers = {
            "method": "POST",
            "PRIVATE-TOKEN": gitlab_private_token,
            "Content-Type": "application/json"
        }
        payload = {
            "branch": "master",
            "commit_message": commit_message,
            "actions": actions
        }
        logger.debug(f"payload={LF}{pprint.pformat(payload)}")

        request = urllib.request.Request(gitlab_uri, json.dumps(payload).encode("utf-8"), headers=headers)
        with urllib.request.urlopen(request) as res:
            res_commit = json.loads(res.read())
        logger.debug(f"gitlab res_commit={LF}{pprint.pformat(res_commit)}")

        # git diff してログに出力
        # https://docs.gitlab.com/ee/api/commits.html
        gitlab_uri = f"{gitlab_base_uri}api/v4/projects/{gitlab_project_id}/repository/commits/{res_commit['id']}/diff"
        logger.info(f"git diff ( {res_commit['id']} )")
        headers = {
            "PRIVATE-TOKEN": gitlab_private_token,
        }
        request = urllib.request.Request(gitlab_uri, headers=headers)
        with urllib.request.urlopen(request) as res:
            res_diff = json.loads(res.read())
        logger.info(f"gitlab res_diff={LF}{pprint.pformat(res_diff)}")

        count_commit += 1
        actions = list()

    logger.info(f"{count_commit} 件 git commit しました")


if __name__ == '__main__':
    try:
        main()
    except Exception as ee:
        logger.exception(ee)
```

## 1回目の試行

1回目は、GitLabのリポジトリが空の状態で実行します。
端末で以下を実行します。

```bash

python3 ethercalc_backup.py
```

実行メッセージの最後に以下のように表示されました。

```bash

2 件 git commit しました
```

GitLabの画面でプロジェクトを確認します。
以下のように 2 Commits になっていて、ethercalcディレクトリが新規作成されています。

![2020-08-17-15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/ecc5b014-8099-f57a-d112-38fafde8cc08.png)

ethercalcの下に入ってみると、以下のように fooとbarの 2種類の commit があり、1つの commit につき SocialCalc形式と csv形式の2つのファイルが新規作成されていました。

![2020-08-17-16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/fa74089d-2d08-7c4b-6b56-47a456fed7a8.png)

ファイル名をクリックすると、中身を確認することができます。

## 2回目の試行

2回目は、EtherCalcで fooの内容のみ変更して Pythonコードを動かしてみます。
以下のように、Hello等と適当に書き加えました。

![2020-08-17-17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/27afc401-0eec-cdd5-fb80-63f94378a09a.png)

端末で以下を実行します。

```bash

python3 ethercalc_backup.py
```

実行メッセージの最後に以下のように表示されました。

```bash

1 件 git commit しました
```

GitLabの画面でプロジェクトを確認します。
以下のように 3 Commits になっています。

![2020-08-17-18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/bc31c939-1d0f-bb81-694c-0e75092d2798.png)

3 Commitsをクリックすると、以下のように最近の fooの commitが追加されている一方で、barは何も追加されていません。

![2020-08-17-20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/51b331e3-d3d4-e703-bd39-1bfd5a03a1de.png)

追加された fooの commit をクリックすると、以下のように前回の commit との差分が表示されました。

![2020-08-17-21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/02c56209-8839-17db-0a66-141b3d2f16ae.png)

## 3回目の試行

3回目は、EtherCalcを変更せずに Pythonコードを動かしてみます。

```bash

python3 ethercalc_backup.py
```

実行メッセージの最後に以下のように表示されました。

```bash

0 件 git commit しました
```

以上です。