Chrome拡張機能でJenkinのビルド手順の説明を上書きする

![](https://qiita-image-store.s3.amazonaws.com/0/206881/74357d8f-c89b-1666-ced1-c4d875eb59a5.png)

# はじめに
Jenkinsでフリースタイルのジョブを作成or編集するときに、__「Windowsバッチコマンドの実行」「シェルの実行」__などの同じ見出しが並ぶため対象の手順を探しづらいなぁと感じていたので、ビルド手順にコメントを書けるようにしてみました。
今回作成したChrome拡張機能は[GitHub](https://github.com/skanmera/jenkins-description-editor)にアップロードしてます。

# アプローチ
- __Jenkinsプラグインでヘッダを上書きする__<br>
    -> ヘッダの文字列を生成している部分がstaticになっていて、すんなりいかなそうだったので__却下__
<br><br>
- __Jenkinsプラグインで既存のビルド手順をコメント欄を付加したものでラップする__<br>
    -> ビルド手順毎に作成するのが面倒かつ余計見づらくなりそうなので__却下__
<br><br>
- __Jenkinsを弄ってCSSで上書きできるようにする__<br>
    -> 運用中のプロジェクトに影響を与えたくないので__却下__
<br><br>
- __Chrome拡張機能でヘッダを上書きする__<br>
    -> 同僚からの提案もあり、Chrome拡張機能とJavaScriptに入門するいい機会だったのでこの方法を__採用__

# 実装

### クラス定義

Jenkinsが生成するHTMLの中身を確認してみます。

![](https://qiita-image-store.s3.amazonaws.com/0/206881/8d48f4b0-f453-a396-8274-4cacb555d1f6.png)

- ビルド手順はbuilderというname属性を持つ要素で構成されている
- id属性はあるが、リロードしたりすると変わる

ということが分かったので、何個目のbuilder要素のヘッダが〇〇でヘルプが△△という覚え方をしておけば良さそうです。

ということで上書き情報を保持するためのクラスを定義とその配列を定義します。

```js:content.js
// ビルド手順の情報を保持しJSON形式で保存される
var BuildStep = function (id, index, header, help) {
  this.id = id;         // id属性値(並び変えや削除に対応するために一時的に保持する)
  this.index = index;   // 全てのbuiler要素中のインデックス
  this.header = header; // ヘッダのテキスト
  this.help = help;     // ヘルプのテキスト
};

var buildSteps = [];

```

### 上書きする文字列を入力するためのダイアログを作成する

__jQuery__を使うと簡単にできるみたいなので使います。

ダイアログの中身となる要素を挿入します。

```js:content.js
// 上書きする文字列を入力するための要素を挿入する
$('body').append("<div id='header_element'><input id='header_text' type='text'></div>");
$('body').append("<div id='help_element'><textarea id='help_text'/></div>");
```

ダイアログを作成するための情報を定義します。

```js:content.js
const DialogSources = {
  // ヘッダの編集
  EDIT_HEADER: {
    title: 'Edit Build Step Header',
    element: $('#header_element'),
    open: function () {
      this.element.dialog('open')
    },
    close: function () {
      var inputText = $('#header_text')[0].value;
      if (inputText && selectedIndex >= 0) {
        // 現在編集中のbuilder要素に対応するビルド手順の上書き情報を探す
        var buildStep = buildSteps.find(bs => bs.index == selectedIndex);
        if (buildStep) {
          // 既に存在する場合は更新する
          buildStep.header = inputText;
        }
        else {
          // 存在しない場合はは新規に作成して追加
          var builder = getBuilder(selectedIndex);
          if (builder) {
            buildSteps.push(new BuildStep(builder.id, selectedIndex, inputText, ''));
          }
        }
        // 保存して上書き
        save();
        overwriteHeaders();
      }
      $(this).dialog('close');
    }
  },
  // ヘルプの編集
  EDIT_HELP: {
    title: 'Edit Build Step Help',
    element: $('#help_element'),
    open: function () {
      this.element.dialog('open')
    },
    close: function () {
      var inputText = $('#help_text')[0].value;
      if (inputText && selectedIndex >= 0) {
        // 現在編集中のbuilder要素に対応するビルド手順の上書き情報を探す
        var buildStep = buildSteps.find(bs => bs.index == selectedIndex);
        if (buildStep) {
          // 既に存在する場合は更新する
          buildStep.help = inputText;
        }
        else {
          // 存在しない場合はは新規に作成して追加
          var builder = getBuilder(selectedIndex);
          if (builder) {
            buildSteps.push(new BuildStep(selectedIndex, '', inputText));
          }
        }
        // 保存して上書き
        save();
        overwriteHelp();
      }
      $(this).dialog('close');
    }
  }
};
```

```title```   : ダイアログのタイトル
```element``` : ダイアログの中身の要素(先ほど挿入した要素)
```open```    : 開く処理
```close```   : 閉じる処理

※```selectedIndex```は現在編集中のbuilder要素のインデックスです。

定義した情報でダイアログを作成します。

```js:content.js
function createDialogs() {
  function create(source) {
    source.element.dialog({
      autoOpen: false,
      title: source.title,
      modal: true,
      resizable: true,
      buttons: {
        "Save": source.close
      }
    });
  };

  for (var key in DialogSources)
    create(DialogSources[key]);
```


### 上書き処理を実装する

```js:content.js
// 全てのbuilder要素のヘッダを上書きする
function overwriteHeaders() {
  var builders = document.getElementsByName('builder');
  for (var i = 0; i < builders.length; i++) {
    var builder = builders[i];
    var buildStep = buildSteps.find(bs => bs.index == i);
    if (buildStep) {
      // 最初のbタグを上書きの対象にする
      builder.getElementsByTagName('b')[0].innerText = buildStep.header;
    }
  }
};

// builder要素のヘルプを上書きする
function overwriteHelp() {
  builder = document.getElementsByName('builder')[selectedIndex];
  if (builder) {
    var buildStep = buildSteps.find(bs => bs.index == selectedIndex);
    if (buildStep && buildStep.help) {
      // Jenkinsがヘルプテキストを生成し終えるのを待つ必要があるのでsetTimeoutで200msほど待機
      setTimeout(() => {
        // 最初のname属性が'help'の要素を上書きの対象にする
        var helpElement = builder.getElementsByClassName('help')[0].children[0];
        if (helpElement) {
          helpElement.innerText = buildStep.help;
        }
      }, 200);
    }
  }
};
```

ヘッダのほうはページ読み込み時に生成されているので一括で変換します。
ヘルプのほうは？アイコンをクリックしたタイミングで変換します。


### 読み込み、保存処理を実装する

```js:content.js
function load() {
  var jobName = getJobName();
  chrome.storage.local.get(jobName, function (value) {
    try {
      buildSteps = JSON.parse(value[jobName]);
      // 読み込んだビルド手順のidを初期化する
      initializeId();
      overwriteHeaders();
    }
    catch (e) {
      buildSteps = [];
    }
  })
};

function save() {
  var jobName = getJobName();
  chrome.storage.local.set({ [jobName]: JSON.stringify(buildSteps) });
}
```

ここではローカルストレージを使ってますが、チームで共有する場合などは実装を変える必要があります。
筆者も実際は社内のAPIサーバ経由でS3にアップロードしてます。
試してませんが、```chrome.storage.sync```を使えばGoogleアカウントで共有できるみたいです。


### idの初期化処理を実装する

```js:content.js
function initializeId() {
  for (var key in buildSteps) {
    var buildStep = buildSteps[key];
    var builder = getBuilder(buildStep.index);
    if (builder) {
      buildStep.id = builder.id;
    }
    else {
      buildSteps.some(function (v, i) {
        if (v.id == buildStep.id) {
          buildSteps.splice(i, 1);
        }
      });
    }
  }
}
```

やっていることは、保持しているビルド手順のインデックスに対応するbuilder要素のidを割り当てているだけです。


### 挿入、削除時のインデックス再構築処理を実装する

```js:content.js
function reindex() {
  for (var key in buildSteps) {
    var buildStep = buildSteps[key];
    var builder = getBuilder(buildStep.index);
    if (!builder || builder.id != buildStep.id) {
      var b = findBuilder(buildStep.id);
      if (b) {
        buildStep.index = getBuilderIndex(b);
      }
      else {
        buildSteps.some(function (v, i) {
          if (v.id == buildStep.id) {
            buildSteps.splice(i, 1);
          }
        });
      }
    }
  }
}
```

こちらは、initializeId()と逆のことをしています。同じidをもつ要素のインデックスを割り当てています。


### 各イベントを登録する

```js:content.js
function initializeEventListeners() {
  document.addEventListener('mousedown', function (event) {
    var element = event.srcElement;
    while (element != null) {
      if (element.getAttribute('name') == 'builder') {
        // builder要素の中身がクリックされた
        selectedIndex = getBuilderIndex(element);
        if (event.button == 2) {
          // 右クリックでコンテキストメニューに追加
          chrome.runtime.sendMessage({ cmd: "create_context_menu" });
        }
        else if (event.button == 0 && event.srcElement.className == "icon-help icon-sm") {
          // 左クリックかつ?アイコンをクリックした場合は、ヘルプを上書き
          overwriteHelp();
        }
        return;
      }
      element = element.parentElement;
    }

    // builder要素以外の箇所をクリックでコンテキストメニューを削除
    // 削除しないと関係ない箇所でも表示される
    chrome.runtime.sendMessage({ cmd: "delete_context_menu" });
  });

  // idが変わるタイミングが不明なのでマウスアップで必ずインデックスを再構築するようにする
  document.addEventListener('mouseup', function (event) {
    reindex()
  });

  // 保存、反映ボタンで上書き情報を保存する
  window.onload = function () {
    document.getElementsByName('Submit')[0].onclick = function () {
      save();
    }
    document.getElementsByName('Apply')[0].onclick = function () {
      save();
    }
  }

  // コンテキストメニューが選択されたときの処理
  chrome.extension.onMessage.addListener(function (msg, sender, sendResponse) {
    if (msg.action == 'open_header_edit_dialog') {
      DialogSources.EDIT_HEADER.open();
    }
    else if (msg.action == 'open_help_edit_dialog') {
      DialogSources.EDIT_HELP.open();
    }
    else if (msg.action == 'reset') {
      buildSteps = [];
      save();
    }
  });
}
```


### background.jsを実装する

```js:background.js
var ContextMenuTitles = [
  'Edit Header',
  'Edit Help',
  'Reset',
]

chrome.contextMenus.onClicked.addListener(function (info, tab) {
  if (info.menuItemId == 'Edit Header') {
    chrome.tabs.query({ active: true }, function (tabs) {
      chrome.tabs.sendMessage(tab.id, { action: "open_header_edit_dialog" }, function (response) { });
    });
  }
  else if (info.menuItemId == 'Edit Help') {
    chrome.tabs.query({ active: true }, function (tabs) {
      chrome.tabs.sendMessage(tab.id, { action: "open_help_edit_dialog" }, function (response) { });
    });
  }
  else if (info.menuItemId == 'Reset') {
    chrome.tabs.query({ active: true }, function (tabs) {
      chrome.tabs.sendMessage(tab.id, { action: "reset" }, function (response) { });
    });
  }
  return true
});

chrome.runtime.onMessage.addListener(function (request) {
  if (request.cmd == "create_context_menu") {
    chrome.contextMenus.removeAll(function () {
      ContextMenuTitles.forEach(function (title) {
        chrome.contextMenus.create({
          title: title,
          type: 'normal',
          id: title,
          contexts: ['all'],
        });
      });
    });
  } else if (request.cmd == "delete_context_menu") {
    chrome.contextMenus.removeAll();
  }
  return true
});
```

background.jsでやっていることは、content.jsからのメッセージを受け取って、対応するメッセージを送り返しているだけです。


### manifest.jsonを作成する

```json.manifest.json
{
  "manifest_version": 2,
  "name": "Jenkins Description Editor",
  "description": "Overwrite build step header and help.",
  "version": "1.0",
  "icons": {
    "16": "images/icon/icon_16.png",
    "48": "images/icon/icon_48.png",
    "128": "images/icon/icon_128.png"
  },
  "background": {
    "scripts": [
      "js/background.js"
    ],
    "persistent": false
  },
  "content_scripts": [
    {
      "matches": [
        "http://localhost:8080/*/configure"
      ],
      "css": [
        "jquery-ui/jquery-ui.css",
        "jquery-ui/jquery-ui.min.css",
        "jquery-ui/jquery-ui.structure.css",
        "jquery-ui/jquery-ui.structure.min.css",
        "jquery-ui/jquery-ui.theme.css",
        "jquery-ui/jquery-ui.theme.min.css"
      ],
      "js": [
        "jquery/jquery-2.1.1.min.js",
        "jquery-ui/jquery-ui.js",
        "jquery-ui/jquery-ui.min.js",
        "js/content.js"
      ]
    }
  ],
  "permissions": [
    "tabs",
    "contextMenus",
    "storage"
  ]
}
```

manifest.jsonに関しては説明しているサイトがたくさんあるので省略します。
matchesはJenkinsのインストール先に変更する必要があります。


## 使ってみる

chrome://extensions で__デベロッパーモード__をONにした状態で、__パッケージ化されていない拡張機能を読み込む__を選択してフォルダを読み込みます。

あとは編集したいジョブの設定ページに行き、ビルド手順のコンテキストメニューから編集を行います。

![](https://qiita-image-store.s3.amazonaws.com/0/206881/9fe88c5b-5a72-a45d-54aa-57fb3500d3bd.png) ![](https://qiita-image-store.s3.amazonaws.com/0/206881/76ff6809-a6f1-910e-9829-48bc04a68488.png)

## 問題点
ダイアログの閉じるボタンのアイコンがjQueryで指定されているURLに存在しないので表示されません。
どうやって解決するのがベターなのか軽く調べた感じでは分からなかったのと、ストアに公開するものでもないので、とりあえずそのままにしてます...


## おわりに

思ってたより実装量が多くなりましたがパッと見で処理が分かりやすくなりました。
Chrome拡張とJavaScriptに触ったのは初めてなので、もし間違いがあれば是非コメントください。