現在ブランチの OPEN PR を常駐監視し、CI fail / Copilot レビュー到着でセッションを起こす (pr-create の background スクリプト置き換え)

個人用のdotfiles設定リポジトリです。 パッケージ管理と Homebrew 管理は別の nix-config リポジトリで行い、このリポジトリは主にシェルやアプリ設定を管理します。
git clone https://github.com/yourusername/dotfiles.git ~/dotfiles
cd ~/dotfiles
cd ~/nix-config
darwin-rebuild switch --flake .#<nix-configで定義したホスト名>
./install.sh
既存の設定ファイルは~/backupディレクトリにバックアップされます。
install.sh: dotfilesのシンボリックリンク作成スクリプトMANUAL_APPS.md: 手動インストールが必要なアプリケーション一覧.config/: 各種アプリケーションの設定ファイル.zshrc: Zshシェルの設定.gitconfig: Git設定.tmux.conf: tmux設定.claude/: Claude Code の設定.codex/: Codex の設定hooks/index.tsx 396 lines1import type { EngineInterface, Register } from "claude-code";
2
3type CiState = "pass" | "fail" | "pending" | "none";
4
5type Threads = { total: number; resolved: number };
6
7type Watched = {
8 prNumber: number;
9 prUrl?: string;
10 lastCi?: CiState;
11 notifiedReview: boolean;
12 notifiedCiFail: boolean;
13 // reviewThreads クエリの節約用: 前回結果と、その時点の Copilot レビュー数
14 threads?: Threads;
15 threadsAtReviews?: number;
16};
17
18let watched: Watched | undefined;
19let pollerStarted = false;
20// タイマーと prompt.submit から同時に走ると通知が多重化するため直列化する
21let polling = false;
22// gh pr view は GraphQL で 1 回あたりの消費が大きく、15 秒 × 複数セッションで上限 (5000/時) を実際に枯渇させたため 60 秒にする
23const MIN_GH_INTERVAL_MS = 60000;
24const MAX_BACKOFF_MS = 900000;
25const RATE_LIMIT_BACKOFF_MS = 600000;
26let nextGhAt = 0;
27let backoffMs = 0;
28let tick = 0;
29let hint: string | undefined;
30// 部位別配色用。監視中のみ設定し、エラー表示等では undefined (hint を dim 単色で出す)
31type Parts = {
32 ci: string;
33 ciColor?: string;
34 review: string;
35 reviewColor?: string;
36 // CI 成功かつレビュー未解決 0 件。スピナーを止める判定に使う
37 done: boolean;
38};
39let parts: Parts | undefined;
40
41// 完了・要対応はポーリング待ちではなく人/モデルの対応待ちなので、回転させず固定記号にする
42function staticGlyph(p: Parts | undefined): string | undefined {
43 if (!p) return undefined;
44 if (p.ciColor === "red" || p.reviewColor === "red") return "✗";
45 if (p.done) return "✓";
46 return undefined;
47}
48
49// 上段ドットのみのフレーム (⠋⠙…) は行の上に寄って見えるため、全 8 点を使う系列にする
50const SPINNER = ["⣷", "⣯", "⣟", "⡿", "⢿", "⣻", "⣽", "⣾"];
51
52const RUNNING_STATES = ["", "PENDING", "IN_PROGRESS", "QUEUED", "EXPECTED"];
53
54function ciSummaryOf(
55 rollup: readonly { state?: string; conclusion?: string; status?: string }[],
56): { state: CiState; done: number; total: number } {
57 const total = rollup.length;
58 if (total === 0) return { state: "none", done: 0, total };
59 const states = rollup.map((c) =>
60 (c.conclusion || c.state || c.status || "").toUpperCase(),
61 );
62 const done = states.filter((s) => !RUNNING_STATES.includes(s)).length;
63 if (
64 states.some((s) => s === "FAILURE" || s === "ERROR" || s === "TIMED_OUT")
65 ) {
66 return { state: "fail", done, total };
67 }
68 if (done < total) return { state: "pending", done, total };
69 return { state: "pass", done, total };
70}
71
72function setHint(
73 $: EngineInterface,
74 text: string | undefined,
75 next?: Parts,
76): void {
77 if (hint === text) return;
78 hint = text;
79 parts = next;
80 $.ui.invalidate("ui.render");
81}
82
83async function poll($: EngineInterface): Promise<void> {
84 if (polling) return;
85 polling = true;
86 try {
87 await pollOnce($);
88 } finally {
89 polling = false;
90 }
91}
92
93// 明示操作 (/pr-watch・↻ ボタン) は間隔待ちを飛ばして即時取得する
94async function refetch($: EngineInterface): Promise<void> {
95 nextGhAt = 0;
96 await poll($);
97}
98
99async function pollOnce($: EngineInterface): Promise<void> {
100 const now = Date.now();
101 if (now < nextGhAt) return;
102 const run = async (argv: readonly string[]) => {
103 const r = await $.process.run(argv);
104 return r.exitCode === 0 ? r.stdout.trim() : undefined;
105 };
106
107 const branch = await run(["git", "rev-parse", "--abbrev-ref", "HEAD"]);
108 const defaultBranch = (
109 (await run([
110 "git",
111 "symbolic-ref",
112 "--short",
113 "refs/remotes/origin/HEAD",
114 ])) ?? ""
115 ).replace(/^origin\//, "");
116 if (!branch || branch === defaultBranch) {
117 watched = undefined;
118 setHint($, undefined);
119 return;
120 }
121
122 const view = await $.process.run([
123 "gh",
124 "pr",
125 "view",
126 "--json",
127 "number,state,url,statusCheckRollup,reviews",
128 ]);
129 const json = view.exitCode === 0 ? view.stdout.trim() : undefined;
130 if (!json) {
131 // 上限超過は分単位で回復しないため、通常の失敗より強く間隔を空ける
132 if (/rate limit/i.test(view.stderr)) {
133 backoffMs = RATE_LIMIT_BACKOFF_MS;
134 nextGhAt = Date.now() + backoffMs;
135 setHint($, "pr-watch 停止中 (GitHub API 上限、10 分後に再試行)");
136 return;
137 }
138 // 失敗の多くは API 上限。間隔を広げて枯渇を助長しない
139 backoffMs = Math.min(
140 backoffMs ? backoffMs * 2 : MIN_GH_INTERVAL_MS,
141 MAX_BACKOFF_MS,
142 );
143 nextGhAt = Date.now() + backoffMs;
144 setHint(
145 $,
146 `pr-watch 待機中 (gh 失敗、${Math.round(backoffMs / 1000)}s 後に再試行)`,
147 );
148 return;
149 }
150 backoffMs = 0;
151 nextGhAt = Date.now() + MIN_GH_INTERVAL_MS;
152 let pr: {
153 number: number;
154 state: string;
155 url?: string;
156 statusCheckRollup?: {
157 state?: string;
158 conclusion?: string;
159 status?: string;
160 }[];
161 reviews?: { author?: { login?: string } }[];
162 };
163 try {
164 pr = JSON.parse(json);
165 } catch {
166 return;
167 }
168 if (pr.state !== "OPEN") {
169 watched = undefined;
170 setHint($, undefined);
171 return;
172 }
173
174 const copilotReviews = (pr.reviews ?? []).filter((r) =>
175 (r.author?.login ?? "").startsWith("copilot"),
176 ).length;
177
178 if (!watched || watched.prNumber !== pr.number) {
179 watched = {
180 prNumber: pr.number,
181 notifiedReview: false,
182 notifiedCiFail: false,
183 };
184 }
185 const w = watched;
186 w.prUrl = pr.url;
187
188 const { state: ci, done, total } = ciSummaryOf(pr.statusCheckRollup ?? []);
189
190 // レビュースレッドは isResolved のみの軽量クエリで全件/解決済みを数える。
191 // 新しいレビューが来たか未解決が残っている時だけ再取得する (指摘 0 件・全解決なら止める)
192 const needThreads =
193 copilotReviews > 0 &&
194 (w.threadsAtReviews !== copilotReviews ||
195 (w.threads !== undefined && w.threads.total - w.threads.resolved > 0));
196 let threads = w.threads;
197 if (needThreads && pr.url) {
198 const m = pr.url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\//);
199 if (m) {
200 const r = await $.process.run([
201 "gh",
202 "api",
203 "graphql",
204 "--paginate",
205 "-F",
206 `owner=${m[1]}`,
207 "-F",
208 `repo=${m[2]}`,
209 "-F",
210 `number=${w.prNumber}`,
211 "-f",
212 "query=query($owner:String!,$repo:String!,$number:Int!,$endCursor:String){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100,after:$endCursor){pageInfo{hasNextPage endCursor}nodes{isResolved}}}}}",
213 "--jq",
214 ".data.repository.pullRequest.reviewThreads.nodes[].isResolved",
215 ]);
216 if (r.exitCode === 0) {
217 const flags = r.stdout.split("\n").filter((l) => l.trim() !== "");
218 threads = {
219 total: flags.length,
220 resolved: flags.filter((f) => f.trim() === "true").length,
221 };
222 w.threads = threads;
223 w.threadsAtReviews = copilotReviews;
224 }
225 }
226 }
227
228 const review = threads
229 ? `レビュー ${threads.resolved}/${threads.total}`
230 : w.notifiedReview
231 ? "レビュー通知済"
232 : copilotReviews > 0
233 ? "レビュー到着"
234 : "レビュー待ち";
235 const ciLabel = total > 0 ? `CI:${ci} ${done}/${total}` : `CI:${ci}`;
236 const ciColor =
237 ci === "pass" || ci === "none"
238 ? "green"
239 : ci === "fail"
240 ? "red"
241 : ci === "pending"
242 ? "cyan"
243 : undefined;
244 const reviewColor = !threads
245 ? "cyan"
246 : threads.total - threads.resolved > 0
247 ? "red"
248 : "green";
249 const allDone =
250 (ci === "pass" || ci === "none") &&
251 threads !== undefined &&
252 threads.total - threads.resolved === 0;
253 setHint($, `pr-watch #${w.prNumber} ${ciLabel} ${review}`, {
254 ci: ciLabel,
255 ciColor,
256 review,
257 reviewColor,
258 done: allDone,
259 });
260
261 // 全完了は一度だけ起こす。未完了に戻ったら (再 push 等) 次の完了も拾えるよう解除する
262 const doneKey = `notified-done:${w.prNumber}`;
263 if (!allDone) {
264 if (await $.store.get(doneKey)) await $.store.set(doneKey, "");
265 } else if (!(await $.store.get(doneKey))) {
266 await $.store.set(doneKey, "1");
267 await $.prompt.submit({
268 text: `PR #${w.prNumber} の CI が全て完了し、Copilot レビューの未解決スレッドも無い。ship フロー中なら ship の手順に従い次のフェーズへ進め。`,
269 });
270 }
271
272 if (ci !== w.lastCi) {
273 // fail から抜けたら通知済みフラグを戻し、次の fail も拾えるようにする
274 if (ci !== "fail") w.notifiedCiFail = false;
275 if (ci === "fail" && !w.notifiedCiFail) {
276 w.notifiedCiFail = true;
277 await $.prompt.submit({
278 text: `PR #${w.prNumber} の CI が fail した。gh pr checks ${w.prNumber} で原因を確認し、修正して push せよ。`,
279 });
280 }
281 w.lastCi = ci;
282 }
283
284 // 増分ではなく未解決スレッドの有無で判定する (監視開始前に到着したレビューも拾うため)
285 if (copilotReviews > 0) {
286 const notifyKey = `notified-review:${w.prNumber}`;
287 // 件数不明 (取得失敗) は "?" のままにして誤起床を防ぐ
288 const unresolved = threads ? String(threads.total - threads.resolved) : "?";
289 if (unresolved === "0") {
290 await $.store.set(notifyKey, "0");
291 return;
292 }
293 // 件数不明では起こさない (API 上限等の一時失敗で誤起床しないため)
294 if (unresolved === "?") return;
295 // モジュールのリロードで通知済みフラグが消えないよう store に持たせる
296 if ((await $.store.get(notifyKey)) === unresolved) return;
297 await $.store.set(notifyKey, unresolved);
298 w.notifiedReview = true;
299 await $.prompt.submit({
300 text: `PR #${w.prNumber} に Copilot レビューの未解決スレッドが ${unresolved} 件ある。ship フロー中なら ship の手順に従い、pr-review-fix スキルで対応せよ。件数が ? (取得失敗) の場合は fetch_unresolved_threads.sh で確認してから判断すること。`,
301 });
302 }
303}
304
305// リロード時はタイマー破棄 + session.start 非再発火のため、任意のフックから遅延起動できるようにする
306function ensurePoller($: EngineInterface): void {
307 if (pollerStarted) return;
308 pollerStarted = true;
309 $.clock.every(5000, () => poll($));
310 // 回転グリフはアニメーションなので gh ポーリングとは別の短周期で回す
311 $.clock.every(120, () => {
312 if (!hint) return;
313 if (staticGlyph(parts)) return;
314 tick = (tick + 1) % SPINNER.length;
315 $.ui.invalidate("ui.render");
316 });
317}
318
319export const register: Register = (on) => {
320 on("session.start", async ($, e, next) => {
321 await $.command.register({
322 name: "pr-watch",
323 description: "pr-watch の監視状態を今すぐ更新して表示する",
324 });
325 ensurePoller($);
326 return next(e);
327 });
328
329 on("command.run", { command: "pr-watch" }, async ($, _e, _next) => {
330 await refetch($);
331 return {
332 text: hint ?? "pr-watch: 監視対象なし (PR のあるブランチにいない)",
333 };
334 });
335
336 on("prompt.submit", ($, e, next) => {
337 ensurePoller($);
338 void poll($);
339 return next(e);
340 });
341
342 // 1 行で足りるので、$.ui.status (黄色) や Pane ではなくプロンプト直上の帯に描く
343 on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
344 if (!hint) return next(e);
345 const { Box, Text, Button } = $.ui.resolve(e);
346 const url = watched?.prUrl;
347 if (!parts || !watched) {
348 return <Text dimColor>{`${SPINNER[tick]} ${hint}`}</Text>;
349 }
350 // 失敗 (赤) > 未完了 (シアン) > 両方完了 (緑) の優先で CI とレビューの色を合成する
351 const colors = [parts.ciColor, parts.reviewColor];
352 const spinnerColor = colors.includes("red")
353 ? "red"
354 : colors.includes("cyan")
355 ? "cyan"
356 : colors.every((c) => c === "green")
357 ? "green"
358 : undefined;
359 // Button のラベルは単色のため、クリック対象は「pr-watch #番号」までにして残りを部位別の色で描く
360 return (
361 <Box>
362 <Text color={spinnerColor} dimColor={!spinnerColor}>
363 {`${staticGlyph(parts) ?? SPINNER[tick]} `}
364 </Text>
365 {url ? (
366 <Button
367 key="pr-watch-ci"
368 label={`pr-watch #${watched.prNumber}`}
369 plain
370 dimColor
371 onPress={() => void $.process.run(["open", `${url}/checks`])}
372 />
373 ) : (
374 <Text dimColor>{`pr-watch #${watched.prNumber}`}</Text>
375 )}
376 <Text> </Text>
377 <Text color={parts.ciColor} dimColor={!parts.ciColor}>
378 {parts.ci}
379 </Text>
380 <Text> </Text>
381 <Text color={parts.reviewColor} dimColor={!parts.reviewColor}>
382 {parts.review}
383 </Text>
384 <Text> </Text>
385 <Button
386 key="pr-watch-refetch"
387 label="↻"
388 plain
389 dimColor
390 onPress={() => void refetch($)}
391 />
392 </Box>
393 );
394 });
395};
396