BitBucket の落日

去年の 4 月に BitBucket の仕様が変更になって

  • Snippets1 が有料プランのみに解放に
  • 無償ユーザーのリポジトリ容量の上限が 1GB に

あたりのサービス改悪が発生した時点で嫌な予感はしてたのだが、お次は今年の 8 月に

  • Issue の完全廃止 … プロジェクト課題管理ツールの JIRA への移行は可能
  • Wiki の完全廃止 … ローカルあるいは Pipeline2 で生成したドキュメントを Pages3 で公開は可能

まで来やがった、こりゃ次は無償 Git ホスティングそのもののサ終もそう遠くなさそうである。 まぁ Mercurial ホスティングなんていう先見の明の無い企業が GitHub に追いつけるわけもなく…

まぁ Bitbucket の親会社、暮らし安心 Atlassian の稼ぎ頭は非 IT 企業への JIRA 導入なので、もはや金も落とさない宣伝にもならない俺みたいな乞食の趣味プログラマーなんか養う理由も無いからな。 こっちでは Williams F1 チームのタイトルスポンサー やれるくらいは儲かってるそうなんだが、財務諸表見るとキャッシュフローが以下略

移行先は Codeberg

ならず者国家アメリカのテック右派や加速主義者に銭が落ちる(まぁ乞食なので払ってないけど)ようなサービスは金輪際使いたくないのである。 つーことでドイツの非営利団体 Codeberg を使うことにして、細かいプロジェクトは去年の段階でそっちに移してはあったんだよね。

しかしオレオレ N6 と pkgsrc は Wiki と Issue の移行がめんどくさくて放置してたんですわ。

Issue は JSON 形式で添付ファイル込みでエクスポートできるのだけど4、Codeberg が使ってる Gitea の fork である Forgejo の API 側の問題でにっちもさっちもいかないのだ。

というのも Issue 作成時の API に作成日時を明示的に指定する方法がないので API 叩いた日になってしまうのである。

{
  "assignee": "string",
  "assignees": [
    "string"
  ],
  "body": "string",
  "closed": true,
  "due_date": "2026-04-16T15:54:30.220Z",
  "labels": [
    0
  ],
  "milestone": 0,
  "ref": "string",
  "title": "string"
}

Codeberg 公式の移行ツールで GitHub から移行した場合はちゃんと作成日時が引き継がれるのでどっかに方法あるはずなんだけどな。

GitHub を経由する三店方式

ということで、なるべく他人の褌で相撲を取りたい怠惰な趣味プログラマとしては Bitbucket -> GitHub -> Codeberg という移行パスを考えた。

BitBucket -> GitHub への移行ツールは

が存在するのだが、コードが古いようで API に連続してアクセスかけるのか問答無用で 429 Too Many Requests が帰ってきて使い物にならない。 なので適当にランダムな感覚でウェイトをはさむかなんかの改修が必要ですなこりゃ。

つーか最近の BitBucket は ブラウザからスパムコメントをお掃除してるだけで削除荒らしと誤認され IP アドレスごと BAN されたりもする5。 よって無償プランの乞食としては BitBucket 側は API 経由で Issue を吸い出すのではなくエクスポート済の JSON を使った方が安全といえよう。

ということで GitHub に CSV ファイルからバッチで Issue を登録するツールを使うことにする。

あたりが見つかるのだけどもどれも一長一短である。

  • GitHub CVS Tools … Issue を close した理由つまり state_reason 要素をサポートしていない(プルリクに修正パッチあり)
  • GitHub Issues Importer … インポートが途中でサイレントに止まって全件インポートされない
  • Python GitHub Issue Importer … title, body, labels の要素のみのサポート

ということで GitHub CVS Tools を選んだ、このツール向けのクッソ雑な JSON -> CSV 変換ツールはこちら。 クローズ日時は雑に最終更新日を使ってるので苦労したい人は logs 要素からステータス変更履歴を探すといい。

#!/usr/bin/perl

use HTTP::Date qw/str2time/;
use JSON;
use Text::CSV_XS;
use Time::Piece qw/gmtime/;

my @csv_header = (
	'number',
	'title',
	'state',
	#'state_reason',
	'labels',
	'milestone',
	'user',
	'assignee',
	'assignees',
	'created_at',
	'updated_at',
	'closed_at',
	'body'
);
my $state = {
	'new'		=> 'open',
	'open'		=> 'open',
	'on hold'	=> 'open',
	'resolved'	=> 'closed',
	'duplicate'	=> 'closed',
	'invalid'	=> 'closed',
	'wontfix'	=> 'closed',
	'closed'	=> 'closed'
};
#my $state_reason = {
#	'new'		=> '',
#	'open'		=> '',
#	'on hold'	=> '',
#	'resolved'	=> 'completed',
#	'duplicate'	=> 'duplicate',
#	'invalid'	=> 'not_planned',
#	'wontfix'	=> 'not_planned',
#	'closed'	=> 'completed'
#};
my $state_label = {
	'duplicate'	=> 'duplicate',
	'invalid'	=> 'invalid',
	'wontfix'	=> 'wontfix'
};
my $kind_label = {
	'bug'		=> 'bug',
	'enhancement'	=> 'enhancement',
	'proposal'	=> 'question',
	'task'		=> 'help wanted'
};
my $usermap = {
	'557058:bfe6b7f6-83ba-4212-8776-6079e66fa0c6'	=> 'tnozaki'
};
open(FH, '< db-2.0.json');
my $json_text = <FH>;
close(FH);
$json = JSON->new->allow_nonref;
$db = $json->decode($json_text);
my $csv = Text::CSV_XS->new ({ binary => 1, auto_diag => 1 });
open(FH, '> issue.json');
$csv->say(*FH, \@csv_header);
foreach my $issue (@{$db->{issues}}) {
	my @line;
	push(@line, $issue->{id});
	push(@line, $issue->{title});
	my $state = $state->{$issue->{status}};
	push(@line, $state);
	#push(@line, $state_reason->{$state});
	my @label;
	my $kind = $issue->{kind};
	push(@label, $kind_label->{$kind}) if (exists($kind_label->{$kind}));
	push(@label, $state_label->{$state}) if (exists($state_label->{$state}));
	push(@line, join(',', @label));
	push(@line, '');
	push(@line, $usermap->{$issue->{reporter}->{account_id}});
	my $assignee = $usermap->{$issue->{assignee}->{account_id}};
	push(@line, $assignee);
	my @assignees;
	push(@assignees, $assignee);
	push(@line, join(',', @assignees));
	push(@line, gmtime(str2time($issue->{created_on}))->strftime("%Y-%m-%dT%TZ"));
	my $update_at = gmtime(str2time($issue->{updated_on}))->strftime("%Y-%m-%dT%TZ");
	push(@line, $update_at);
	my $closed_at = ($state eq "closed") ? $update_at : '';
	push(@line, $closed_at);
	push(@line, $issue->{content});
	$csv->say(*FH, \@line);
}
close(FH);

こいつで変換した issue.cvs を

$ npm install github-csv-tools
$ githubCsvTools -t <API Token> -o <user> -r <repository> issue.csv

として流し込めばよいはずである。

ダメでした

ところがですね、流し込んだ結果をみて気づいたのだが以下の問題があるのだ。

  • Issue 作成日時/最終更新日/クローズ日時が引き継がれない

確認したら GitHub の Issue の 作成 API にも作成日時の指定方法が無かったわ。

curl -L \
  -X POST \
  -H "Accept: application/vnd.github+json" \
  -H "Authorization: Bearer <YOUR-TOKEN>" \
  -H "X-GitHub-Api-Version: 2026-03-10" \
  https://api.github.com/repos/OWNER/REPO/issues \
  -d '{"title":"Found a bug","body":"I'\''m having a problem with this.","assignees":["octocat"],"milestone":1,"labels":["bug"]}'

それ以外にも

  • コメントのインポートに対応していない

という問題もある、コメントを本文中に埋め込むしか無いですかねこれ。

さらには GitHub 側の問題として以下の情報欠落も発生してしまう。

  • Markdown 本文中への埋め込み画像に対応してない
  • 添付ファイルに対応していない

さすがに uuencode して本文埋め込みとかやりたくねえよクソが。

つまり 日付を喪失するのを許容するならば Codeberg の API 直接叩いた方がマシという結論である、また無駄なコードを書いてしまった…

他に方法は無いのか

あと思いつく方法としてはどっかに自前の Forgejo サーバー建てて、直接 SQL で移行データ流し込んどいて Forgejo -> Codeberg の公式移行ツール使うですかね。 インストールは Linux はシングルバイナリ落として実行するだけなので非常に簡単、Docker イメージもあるけど使う必要すらねえなこれ。

ということで一度ローカルに Forgejo 入れてデータベースのスキーマを調べて BitBucket の Issue との変換スクリプト書きますかね。 それが完成したら短期間だけ VPS 契約するかね、あーそんな金あったら酒に使いたい…

Git 以外の選択肢

そもそもこんな移行問題は Version Control/Issue Tracking/Wiki が一体化してる Fossil ならば発生しないのだよな、つまり Git は滅びるべきで今すぐお前ら使うのを止めろ。

とはいえ Fossil のホスティングしてるところって Chisel くらいしか無くてな、これもいつまでサービス続くことやら。 それ以外にも今どき複数要素認証も対応してないあたりセキュリティが心配でもある。

それにしても Fossil (化石) に Chisel (鑿) とか検索に不利な名前つける風習を IT 業界は反省した方がいい。 Chef (料理人) に Cookbooks (料理本) なんかはマジで正気を疑ったぞ、まぁ Citrus (柑橘類) は俺が命名したわけじゃないからセーフ。

BitBucket を使い続ける選択肢

移行諦めて Issue から JIRA の無償プランに移行して BitBucket を使い続けてもいいのだけど、もうじき 1GB リミットに引っかかりそうなのとコミットログ中の Issue への自動リンクが全部リンク切れになるのが厄介である。 設定で #1 を PROJECT-1 に飛ばすような機能も用意せずに JIRA 使えって態度だから誰も BitBucket なんか使わねえんだわ、バーカバーカ。

  1. GitHub Gist みたいなもん 

  2. GitHub Action みたいなもん 

  3. GitHub Pages みたいなもん 

  4. 実はこいつにもひとつ問題があって、Markdown 本文中に埋め込んだ画像ファイルはエクスポートデータに含まれない、自分でイメージリンクを抜き出して回収する必要がある。 

  5. 正確には IP アドレス + User-Agent なので UA 偽装で回避できる。