はじめに
MIXI DEVELOPERS Advent Calendar 2025の13日目の記事です!
現在mixi2のブラウザ版の開発を担当しているのですが、今回はLinearで管理しているタスクの進捗をGitHub Actions + GitHub Pagesで可視化した話について書きたいと思います。
アプリ版は今年の12月16日でリリースから1周年になります!
ブラウザ版はアプリの後を追って5月にリリースされ、主にアプリ版相当の機能を追加していっている段階になります。
現状ブラウザ版の開発で「いつ必要な機能がリリースできるのか」なかなか見通しが立たないという課題に対して、まずは現状を可視化すべく、チームのベロシティのグラフとバーンアップチャートを半日ほどで整備しました。
背景
弊チームではタスク管理にLinearを使用しています。
Linearはソフトウェア開発向けの高機能なプロジェクト管理ツールです。
これを使って「ユーザーストーリー/不具合/実装タスク」をまとめて管理しています。
現在各種機能のリリースを続けているものの、今までのリリース量から残っている機能をいつまでにリリースできるかを見積もる仕組みがありませんでした。
Linearにはチャート機能がありますが、有料プランじゃないと利用できず、これだけのために課金するというのはもったいなく感じています。
また、ありがたいことにすでにGitHub Actionsを使ってLinearのAPIを叩き、リリースに連動してLinearのタスクを自動でCloseする仕組みが存在していたため、同じ基盤でベロシティも見える化し、スプレッドシート運用などのコストを避ける方針にしました。
可視化の結果
GitHub Pagesの設定で指定したブランチに毎週静的ページをpushしてホスティングし、バーンアップチャート/ベロシティ/完了見込み日をいつでも確認できるようにしました。
なおここでいうベロシティとは、1週間あたりのリリースしたユーザーストーリーの単純な数としています。
スクショはサンプルデータでの出力なのでやや不自然なのはご了承ください。
仕組みの全体像
-
.github/workflows/linear-initiative-burnup.yaml- GitHub Actionsのワークフローファイル
- schedule機能を使って毎週自動実行
-
LINEAR_API_KEYを使ってレポート生成。 - 生成物を
gh-pagesブランチにpushして公開
-
linear-initiative-burnup.ts- Linearでカウントしたい対象のProjectやIssue情報をAPI経由で収集。
- 完了日時をもとにバーンアップ用CSVと2種類のSVG描画し、HTMLをテンプレート差し込みで生成
-
initiative-burnup.html- GitHub Pagesで表示するテンプレートとなるhtmlファイル
- ダウンロードボタン、ベロシティ計算窓の入力、完了見込み日をページ上で再計算する軽いインタラクションを持つ
実装コードの抜粋
ほとんどcodexに任せていて読みにくいので、雰囲気だけ伝わるように抜粋して紹介します。
.github/workflows/linear-initiative-burnup.yaml
name: linear-initiative-burnup
on:
workflow_dispatch:
schedule:
- cron: "0 21 * * 2" # 週1回の実行
# ...
jobs:
generate-burnup:
runs-on: ubuntu-slim
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
# ...
- name: Generate initiative burn-up assets
env:
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
run: |
OUTPUT_DIR="tools/task/output/initiative-burnup"
rm -rf "$OUTPUT_DIR"
node tools/task/src/linear-initiative-burnup.ts "$OUTPUT_DIR"
# gh-pagesブランチへ成果物を push するステップが続く
# ...
linear-initiative-burnup.ts
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { LinearClient } from '@linear/sdk';
const CONFIG = {
INITIATIVE_ID: '...UUID...',
TARGET_LABEL: 'User Story',
DEFAULT_OUTPUT_DIR: 'tools/task/output/initiative-burnup',
CSV_FILENAME: 'initiative-burnup.csv',
SVG_FILENAME: 'initiative-burnup.svg',
WEEKLY_SVG_FILENAME: 'initiative-weekly-throughput.svg',
HTML_FILENAME: 'index.html',
CHART_SIZE: { width: 1280, height: 720 },
} as const;
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const outputDir = resolveOutput(args);
if (args.fixturePath) {
await runWithFixture(args.fixturePath, outputDir);
return;
}
const apiKey = process.env.LINEAR_API_KEY;
if (!apiKey) throw new Error('LINEAR_API_KEY が設定されていません');
await runWithLinear(apiKey, outputDir);
}
async function runWithLinear(apiKey: string, paths: OutputPaths): Promise<void> {
const client = new LinearClient({ apiKey });
const initiative = await findInitiative(client, CONFIG.INITIATIVE_ID);
const [projectLabelId, issueLabelId] = await Promise.all([
getProjectLabelId(client, CONFIG.TARGET_LABEL),
getIssueLabelId(client, CONFIG.TARGET_LABEL),
]);
const [projects, issues] = await Promise.all([
fetchInitiativeProjects(initiative, projectLabelId),
fetchInitiativeIssues(client, initiative.id, issueLabelId),
]);
const combinedItems = [
...projects.map(({ createdAt, completedAt }) => ({ createdAt, completedAt })),
...issues.map(({ createdAt, completedAt }) => ({ createdAt, completedAt })),
];
await generateOutputs({
initiativeName: initiative.name,
labelSummary: CONFIG.TARGET_LABEL,
projectCount: projects.length,
issueCount: issues.length,
items: combinedItems,
paths,
sourceLabel: 'Linear API',
});
}
// fetchInitiativeProjects, fetchInitiativeIssues, generateOutputs など細かい処理が続く
// ...
initiative-burnup.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>__TITLE__</title>
<style>
:root { --bg: #f7f9fb; --accent: #2563eb; /* ... */ }
body { font-family: 'Noto Sans JP', sans-serif; background: radial-gradient(/* ... */); }
/* レイアウトやカード、ボタンのスタイルが続く */
/* ... */
</style>
</head>
<body>
<div class="shell">
<header>
<h1>__TITLE__</h1>
<div class="meta">
<span>対象ラベル: __LABEL_SUMMARY__</span>
<span>データソース: __SOURCE_LABEL__</span>
<span>生成日時 (JST): __GENERATED_AT_JST__</span>
</div>
</header>
<main>
<div class="summary">
<!-- ベロシティ・残数・完了見込み -->
</div>
<div class="actions">
<a class="button" href="./__CSV_FILENAME__" download>CSVをダウンロード</a>
</div>
<div class="chart">
<img src="./__SVG_FILENAME__" alt="burn-up chart" />
</div>
<div class="chart">
<img src="./__WEEKLY_SVG_FILENAME__" alt="weekly throughput chart" />
</div>
</main>
</div>
<script>
const weeklyVelocityCounts = __WEEKLY_VELOCITY_COUNTS__;
const remaining = Number('__REMAINING__');
const defaultWindow = Number('__DEFAULT_VELOCITY_WINDOW__');
// 入力値に応じて平均ベロシティと完了見込みを再計算
// ...
</script>
</body>
</html>
実装のポイント
Vibe Codingできるようにローカル確認の仕組みを作る
プロダクトコードに影響しない実装なので、スピード優先のためAI(codex)を使ってVibe Coding気味で実装し、可読性はあまり重要視していません。
ただしいちいちGitHub Pagesにデプロイして確認・修正を繰り返すのは手間なので、AIでの実装後に手元で確認しながら追加指示を出すサイクルを回せるようにしました。
具体的には、実データがなくてもプレビューできるよう、フィクスチャ付きのCLIを用意してもらいました。サンプルデータのjsonを用意して次のコマンドを実行すれば、そのままローカル環境でindex.htmlを開いて動きを確認できます。
node tools/task/src/linear-initiative-burnup.ts \
--fixture tools/task/fixtures/linear-initiative-burnup.sample.json \
tools/task/output/initiative-burnup
open tools/task/output/initiative-burnup/index.html
もちろんサンプルデータもAIによしなに生成してもらっています。
本番データで生成する場合は LINEAR_API_KEY を用意して node tools/task/src/linear-initiative-burnup.ts を実行させています。
シンプルな構成
ホスティングはすべて静的に完結させました。GitHub Pagesへ直接pushするだけで毎週更新され、別サービスへの依存を持たないため1つのリポジトリでの変更で仕組みづくりが済んでいます。
LinearのAPIを叩いている部分を別APIに置き換えれば、Linearに限らずGitHub Projectsといった他のタスク管理サービスでも同じことができるはずです。
また、HTML側には小さなインタラクションを入れ、ベロシティ計算窓を変更すると完了見込みを即時計算し直すようにしました。
CSVも同時に生成しているので、必要に応じて別ツールに貼って二次利用できます。
もし GitHub Pagesの利用が手間な場合は、リポジトリに専用ブランチを追加してMarkdownやSVGを直接pushするだけの軽量版に切り替えるのも手です。最初はGitHub Pagesを他の用途で利用していて競合する問題があったのでこの形で凌いでいました。
おわりに
可視化してみると「思ったより終わらないのでは?」「ユーザーストーリーごとに重さが違うので見積もりの精度が低いのでは?」といったことが見え始め、「ふりかえり→改善」のループが回り始めた感覚です。
今後もふりかえりながら少しずつ改善を続けていきたいです!
AIのおかげでちょっとしたツールを気軽に作れる時代になりました。同じ課題を抱えるチームの参考になれば幸いです。
