4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GitBucket で issue に開始日・終了日・進捗率等を追加しガントチャートに表示するプラグインを作成してみた

Last updated at Posted at 2025-12-26

概要

GitBucket は、GitHub のようにネットワーク上で Git リポジトリを共有できる Web サービスです。

  • Apache-2.0 ライセンスで配布されている。
  • Scala で開発されており、Java 17 実行環境があれば、起動可能。
  • お試しであれば、内臓のH2データベースエンジンが利用できる。
    • 実運用には、PostgreSQL や MariaDB などのリレーショナルデータベースサーバーの利用を推奨
  • 非常に簡単にセルフホストが可能です。

私は、GitBucket Markdown Enhanced Plugin という GitBucket 標準のマークダウンレンダリングエンジンを置き換えるプラグインを開発中です。

GitBucket のコミュニティプラグインには、既にガントチャートを表示できる Gantt Chart plugin がありますが、期間を設定できないのが、個人的に不満でした。

そこで issue に開始日・終了日・進捗率等を登録し、ガントチャートに表示するプラグイン GitBucket Flexible Gantt Plugin を作成してみました。

その開発の記録です。

現状の成果

現状の成果として、0.1.0 をβリリースしました。

  • まだ、ユーザーへの通知が表示されるべき場面で通知が表示されなかったり、エラー処理が未実装だったりします。
  • 少量のデータでしか、試験しておりません。ガントチャートに表示する issue の絞り込みもできません。

以下のように登録済みの issue を表示すると右側のサイドバーに開始日、終了日、進捗、依存 issue を設定するためのフォームが表示されます。

image.png

新規作成時は表示されません。

リポジトリのサイドバーにある Flexible Gantt というメニューをクリックすると以下のようなガントチャートが表示されます。

image.png

  • ガントチャート上のタスクをクリックすると該当する issue が、別ウィンドウで開きます。
  • 開始日、終了日、進捗をドラッグ&ドロップで更新できます。

作成しようと思ったきっかけ

概要にも書きましたが、GitBucket のコミュニティプラグインには、既にガントチャートを表示できる Gantt Chart plugin がありますが、期間を設定できないのが、個人的に不満でした。

GitBucket Markdown Enhanced Plugin を開発するための情報収集中に以下の記事を見つけました。

この記事では、issue に作業量を設定できる項目を追加し、統計などを取れるようにしたプラグインが紹介されていました。

GitBucket Markdown Enhanced Plugin の開発がひと段落したら、この記事を参考に issue に開始日・終了日を設定し、ガントチャートに表示するプラグインを作成したいと考えていました。

ガントチャートに使用するライブラリ

ガントチャートのライブラリに関する比較記事等を見て検討した結果、Frappe社Frappe Gantt を採用することにしました。

決め手は、以下の通りです。(他のライブラリも同様かもしれませんが…)

  • ガントチャート上で日程・進捗の変更ができ、そのイベントハンドラーを書ける
  • ガントチャート上のタスクをクリックした際のハンドラーを書ける
  • タスクの依存関係を設定できる
  • デザインが気に入った

使用に当たり、以下の記事を参考にしました。

プラグイン用のテーブルをデータベースに追加する

プラグイン用のテーブル ISSUE_PERIOD をデータベースに追加しました。

※わざわざ mermaid の ER図にする必要はないかもしれませんが…

Solidbaseというライブラリを使用

GitBucketプラグインでデータベーステーブルを追加するには、Solidbase というライブラリを使用してスキーマの自動更新を実装します。これにより、プラグインのバージョンアップ時に自動的にデータベーススキーマが更新されるようになります。

Solidbase は、GitBucket の開発者であるたけぞうさんが作成したライブラリです。

GitBucketの起動時、Solidbaseがデータベースのバージョン (VERSIONSテーブルに格納されています) をチェックし、定義されたバージョンとの差分に基づいてスキーマー更新ファイルを実行します。これにより、プラグイン独自のテーブルが自動的に作成されます。

スキーマ更新ファイルの作成

src/main/resources/update/ ディレクトリ配下に、新しいバージョンのスキーマ更新ファイル (SQLファイルまたはXMLファイル) を配置します。

XMLの場合

今回は、XML による方法を採用しました。

src\main\resources\update\gitbucket-flexible-gantt-0.1.0.xml

src\main\resources\update\gitbucket-flexible-gantt-0.1.0.xml
<?xml version="1.0" encoding="UTF-8"?>
<changeSet>
    <!--================================================================================================-->
    <!-- ISSUE_PERIOD -->
    <!--================================================================================================-->
    <createTable tableName="ISSUE_PERIOD">
        <column name="USER_NAME"        type="varchar(100)" nullable="false"/>
        <column name="REPOSITORY_NAME"  type="varchar(100)" nullable="false"/>
        <column name="ISSUE_ID"         type="int"          nullable="false"/>
        <column name="START_DATE"       type="datetime"     nullable="false"/>
        <column name="END_DATE"         type="datetime"     nullable="false"/>
        <column name="PROGRESS"         type="int"          nullable="false"/>
        <column name="DEPENDENCIES"     type="varchar(100)" nullable="false"/>
    </createTable>

    <addPrimaryKey
            constraintName="IDX_ISSUE_PERIOD_PK"
            tableName="ISSUE_PERIOD"
            columnNames="USER_NAME, REPOSITORY_NAME, ISSUE_ID"/>
    <addForeignKeyConstraint
            constraintName="IDX_ISSUE_PERIOD_FK0"
            baseTableName="ISSUE_PERIOD"
            baseColumnNames="USER_NAME, REPOSITORY_NAME, ISSUE_ID"
            referencedTableName="ISSUE"
            referencedColumnNames="USER_NAME, REPOSITORY_NAME, ISSUE_ID"/>
</changeSet>

SQLの場合

SQLファイルを使用する場合、ファイル名は ${moduleId}_${version}.sql の形式に従う必要があります。
例えば、myplugin というモジュールIDで、バージョン 1.0.0 のスキーマ更新を行う場合、以下のファイル名となります。

作成したファイル内にテーブル作成のSQL文を記述します。

src/main/resources/update/myplugin_1.0.0.sql
CREATE TABLE MY_NEW_TABLE (
    ID INT PRIMARY KEY,
    NAME VARCHAR(255)
);

バージョン定義の追加

プラグイン本体に新しいバージョン定義を追加します。

src\main\scala\Plugin.scala

  override val versions: List[Version] = List(
    new Version("0.1.0", new LiquibaseMigration("update/gitbucket-flexible-gantt-0.1.0.xml"))
  )

バージョン番号とともにスキーマ更新ファイルのパスを与えた LiquibaseMigration クラスのインスタンスを指定する必要があります。

プラグイン本体でデータベースに関連した処理を行う場合に出たエラー

当プラグインでは、issue のサイドバー追加を gitbucket.core.plugin.Plugin を継承した Plugin クラスで issueSidebars メソッドを override して実施しています。

該当 issue の情報を取得するため、RepositoryService を同時に継承し、リポジトリの情報と issue の情報を取得していますが、当初、以下のエラーメッセージに悩まされました。

could not find implicit value for parameter s: gitbucket.core.model.Profile.profile.blockingApi.Session

最終的に以下のコードを追加することでエラーを回避することができました。

      implicit val session: Session = Database.getSession(context.request)

GitBucket はデータベースクエリの処理に Slick というライブラリを利用している

GitBucket はデータベースクエリの処理に Slick というライブラリを利用しています。

現在の GitBucket が同梱している Slick は 3.4.1 です。

GitBucketのissueで作業量を設定できるプラグイン #JavaScript - Qiita で紹介されているプラグイン gitbucket-issue-estimation-plugin では、レコードの追加・更新に insertOrUpdate メソッドを使用しています。

これに倣い、insertOrUpdate メソッドを使用したのですが、H2 データベースではうまく動きませんでした。

調査したところ、insertOrUpdate メソッドは、MySQL のみ対応していることが分かりました…。

仕方がないので既存のレコードがなければ insert、あれば update を行うように修正しました。

src\main\scala\io\github\yasumichi\gfg\service\IssuePeriodService.scala

Trait の継承地獄

Trait は、Scala 公式ドキュメントで

それらはJava 8のインターフェースと似ています。

と紹介されていますが、そのつもりでコーディングするとハマります。(あくまで個人の感想であり…)

データベースへの接続は、service 層が担当します。GitBucket では、各 Service は、Trait として定義されています。

先ほどの Trait の公式ドキュメントに以下のように書いてあります。

クラスとオブジェクトはトレイトを継承することができますが、トレイトはインスタンス化ができません

controller 層などから、サービスのデータベース操作を利用する場合、その Trait を継承する必要があります。

Trait には、この Trait を継承する場合は、別の Trait も継承しなければならないという縛りがあるらしく、以下のエラーに何度も苦しめられました。

illegal inheritance;
 self-type io.github.yasumichi.gfg.controller.FlexibleGanttController does not conform to 

認識謝りがありましたら、ご教示いただけますと幸いです。

データベースを扱うサービスは CoreProfile を継承しapi を import した方が良さげ

データベースを扱うサービスは、 gitbucket.core.model.CoreProfile を継承~~しCoreProfile.profile.api を import ~~した方が良いです。

api の実態は、Slickslick.jdbc.JdbcProfile で定義されている api というフィールドのようです。

当プラグインでは、src\main\scala\io\github\yasumichi\gfg\service\IssuePeriodService.scala にその辺の記述があります。

以下で関連する部分のみ抜粋します。

src\main\scala\io\github\yasumichi\gfg\service\IssuePeriodService.scala
trait IssuePeriodService {
  self: CoreProfile
(中略)
    with WritableUsersAuthenticator =>
  import gitbucket.core.service.IssuesService._
  import self.profile.api

CoreProfile を継承しないと filter()join() などのメソッドがコンパイルエラーになりました。

再確認したところ、import self.profile.api はなくてもコンパイル通りました。

issue サイドバー表示の流れ

src/main/scala/Plugin.scala
  override val issueSidebars: Seq[(Issue, RepositoryInfo, Context) => Option[Html]] =
    Seq((issue: Issue, repository: RepositoryInfo, context: Context) => {
      implicit val session: Session = Database.getSession(context.request)
      var isEditable = false
      if (!issue.isPullRequest) {
        if (context.loginAccount.isDefined) {
          isEditable = hasDeveloperRole(repository.owner, repository.name, context.loginAccount)
        }
        Some(html.issuesidebar(repository.owner, repository.name, issue.issueId, isEditable)(context))
      } else None
    })
  • 返却された HTML に埋め込まれている JavaScript が、既存のデータがないか、FlexibleGanttController に AJAX で問い合わせ、フォームの初期値を設定

本当は、Issue サイドバーを返却する時点で初期値を埋め込みたかったのですが、IssuePeriodService を継承しようとした際に Trait 継承地獄にハマったので断念。

src\main\twirl\flexiblegantt\issuesidebar.scala.html
    document.addEventListener('DOMContentLoaded', (e) => {
        const url = "@context.path/@owner/@repositoryName/flexible-gantt/issues/@issueId"
        fetch(url)
        .then(res => {
            if (res.ok) {
                return res.json();
            }
        })
        .then(data =>{
            if (data.period.length == 0) return;
            var period = data.period[0];
            document.querySelector("input[name='startDate']").value = (new Date(Date.parse(period.startDate)))
                .toLocaleDateString("ja-JP", {year: "numeric",month: "2-digit",  day: "2-digit"}).replaceAll('/', '-');
            document.querySelector("input[name='endDate']").value =  (new Date(Date.parse(period.endDate)))
                .toLocaleDateString("ja-JP", {year: "numeric",month: "2-digit",  day: "2-digit"}).replaceAll('/', '-');
            document.querySelector("select[name='progress']").value = period.progress;
            document.querySelector("input[name='dependencies']").value = period.dependencies;
        })
    })
src\main\scala\io\github\yasumichi\gfg\controller\FlexibleGanttController.scala
  ajaxGet("/:owner/:repository/flexible-gantt/issues/:issueId")(readableUsersOnly { repository =>
    context.withLoginAccount { loginAccount =>
      implicit val session: Session = Database.getSession(context.request)
      contentType = formats("json")
      val issueId:Int = params("issueId").toInt
      org.json4s.jackson.Serialization.write(
        "period" ->
          getIssuePeriod(repository.owner, repository.name, issueId)
            .map { t =>
              Map(
                "startDate" -> t.startDate,
                "endDate" -> t.endDate,
                "progress" -> t.progress,
                "dependencies" -> t.dependencies
              )
            }
      )
    }
  })

Issue 開始日等の登録の流れ

src\main\twirl\flexiblegantt\issuesidebar.scala.html
    document.getElementById("gfgpost").addEventListener('click', (e) => {
        const form = document.getElementById("gfgform")
        const params = new URLSearchParams();
        params.append("startDate", document.querySelector("input[name='startDate']").value)
        params.append("endDate", document.querySelector("input[name='endDate']").value)
        params.append("progress", document.querySelector("select[name='progress']").value)
        params.append("dependencies", document.querySelector("input[name='dependencies']").value)
        const action = form.getAttribute("action")
        const options = {
            method: 'POST',
            body: params
        }
        fetch(action, options).then((e) => {
            if(e.status === 200) {
                console.log(e)
                return
            }
            console.log(e)
        })
    })
src/main/scala/io/github/yasumichi/gfg/controller/FlexibleGanttController.scala
  ajaxPost("/:owner/:repository/issues/:issueId/period")(writableUsersOnly { repository =>
    context.withLoginAccount { loginAccount =>
      implicit val session: Session = Database.getSession(context.request)
      val formatter: SimpleDateFormat = new SimpleDateFormat("yyyy-MM-dd")

      val userName = params("owner")
      val repositoryName = params("repository")
      val issueId = params("issueId")

      val startDate: Date = if (params("startDate") == "") null else formatter.parse(params("startDate"))
      val endDate: Date = if (params("endDate") == "") null else formatter.parse(params("endDate"))
      val progress = params("progress")
      val dependencies = params("dependencies")

      logger.info("params get")

      logger.info("upsertIssuePeriod call")
      insertIssuePeriod(userName, repositoryName, issueId.toInt, startDate, endDate, progress.toInt, dependencies)

      org.json4s.jackson.Serialization.write(
        Map(
          "message" -> "updated issue period"
        )
      )
    }
  })

ガントチャート表示の流れ

  • Pluginがリポジトリにサイドバーを表示
src/main/scala/Plugin.scala
  override val repositoryMenus = Seq((repositoryInfo: RepositoryInfo, context: Context) =>
    Some(Link("flexible-gantt", "Flexible Gantt", "/flexible-gantt", Some("dashboard")))
  )
src/main/scala/io/github/yasumichi/gfg/controller/FlexibleGanttController.scala
  get("/:owner/:repository/flexible-gantt") {
    referrersOnly { repository: RepositoryInfo =>
      {
        html.flexiblegantt(repository)
      }
    }
  }
src/main/scala/Plugin.scala
  override def javaScripts(
      registry: PluginRegistry,
      context: ServletContext,
      settings: SystemSettingsService.SystemSettings
  ): Seq[(String, String)] = {

    val path = settings.baseUrl.getOrElse(context.getContextPath)

    Seq(
      ".*/flexible-gantt" ->
        s"""|</script>
          |
          |<link rel="stylesheet" href="$path/plugin-assets/flexible-gantt/frappe-gantt.css">
          |<script type="text/javascript" src="$path/plugin-assets/flexible-gantt/frappe-gantt.umd.js"></script>
          |
          |<script>
          |""".stripMargin
    )
  }
  • FlexibleGanttControllerが返却した HTML に埋め込まれた JavaScript が issue の情報を AJAX で取得
src\main\twirl\flexiblegantt\flexiblegantt.scala.html
                const url = "@context.baseUrl/@repository.owner/@repository.name/flexible-gantt/issues";
                fetch(url)
                .then(res => {
                    if (res.ok) {
                        return res.json();
                    }
                })
src/main/scala/io/github/yasumichi/gfg/controller/FlexibleGanttController.scala
  ajaxGet("/:owner/:repository/flexible-gantt/issues")(readableUsersOnly { repository =>
    context.withLoginAccount { loginAccount =>
      implicit val session: Session = Database.getSession(context.request)
      contentType = formats("json")
      org.json4s.jackson.Serialization.write(
        "list" ->
          getIssuePeriods(repository.owner, repository.name)
            .map { t =>
              Map(
                "id" -> t._2.issueId.toString(),
                "name" -> t._2.title,
                "start" -> t._1.startDate,
                "end" -> t._1.endDate,
                "progress" -> t._1.progress,
                "dependencies" -> t._1.dependencies
              )
            }
      )
    }
  })
  • 返却された JSON を元にガントチャートを描画
src\main\twirl\flexiblegantt\flexiblegantt.scala.html
               .then(data => {
                    const tasks = data.list;
                    var gantt_chart = new Gantt("#gantt", tasks, {
(中略後述のイベントハンドラーの説明に抜粋)
                        on_view_change: function(mode) {
                            console.log(mode);
                        },
                        container_height: document.querySelector(".content-wrapper").clientHeight - 150,
                        popup_on: 'hover',
                        view_mode_select: true,
                        language: 'ja'
                    })
                });

ガントチャートでタスクをクリックすると当該 issue を開くイベントハンドラー

ガントチャートでタスクをクリックした際の処理は、on_click に記述します。

src\main\twirl\flexiblegantt\flexiblegantt.scala.html
                        on_click: function (task) {
                            window.open(issuebase + task.id)
                        },

window.open() で指定した URL を開くだけの簡単なお仕事です。

issuebase別の個所で定義しています。

ガントチャートでドラッグ&ドロップで日付を変更した際のイベントハンドラー

ガントチャートでドラッグ&ドロップで日付を変更した際の処理は、on_date_change に記述します。

src\main\twirl\flexiblegantt\flexiblegantt.scala.html
                        on_date_change: function(task, start, end) {
                            var postUrl = issuebase + task.id + postSuffix;
                            const params = new URLSearchParams();
                            params.append("startDate", start.toLocaleDateString("ja-JP", {year: "numeric",month: "2-digit",  day: "2-digit"}).replaceAll('/', '-'));
                            params.append("endDate", end.toLocaleDateString("ja-JP", {year: "numeric",month: "2-digit",  day: "2-digit"}).replaceAll('/', '-'));
                            params.append("progress", task.progress);
                            params.append("dependencies", task.dependencies);
                            const options = {
                                method: 'POST',
                                body: params
                            };
                            fetch(postUrl, options).then((e) => {
                                if (e.status === 200) {
                                    console.log(e)
                                    return
                                }
                                console.log(e)
                            })
                        },

フォームコントロールの値を URLSearchParams で組み立て、fetch()FlexibleGanttController に登録を依頼しています。

ガントチャートでドラッグ&ドロップで進捗を変更した際のイベントハンドラー

ガントチャートでドラッグ&ドロップで進捗を変更した際の処理は、on_progress_change に記述します。

src\main\twirl\flexiblegantt\flexiblegantt.scala.html
                        on_progress_change: function(task, progress) {
                            var postUrl = issuebase + task.id + postSuffix;
                            const params = new URLSearchParams();
                            params.append("startDate", (new Date(task.start)).toLocaleDateString("ja-JP", {year: "numeric",month: "2-digit",  day: "2-digit"}).replaceAll('/', '-'));
                            params.append("endDate", (new Date(task.end)).toLocaleDateString("ja-JP", {year: "numeric",month: "2-digit",  day: "2-digit"}).replaceAll('/', '-'));
                            params.append("progress", progress);
                            params.append("dependencies", task.dependencies);
                            const options = {
                                method: 'POST',
                                body: params
                            };
                            fetch(postUrl, options).then((e) => {
                                if (e.status === 200) {
                                    console.log(e)
                                    return
                                }
                                console.log(e)
                            })
                        },

前項と同様の処理をしています。

ToDo

  • コメントを真面目に書く
  • 登録時の処理でユーザーにちゃんと通知する
  • 休日等の設定変更を可能にする
  • 依存 issue の入力支援機能をつける
  • ガントチャートの画面から issue を新規作成できるようにする
  • ラベルやマイルストーンで絞り込みできるようにする

参考リンク

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?