SLOPSHOPPER

pr-watch

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

newbandcommandpromptprocesstimer
★ 1v0.1.0no licenseupdated 2026-09-25void2610/dotfiles/.claude/mods/pr-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-watch
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /pr-watch ⎿ pr-watch: pr-watch: 監視対象なし (PR のあるブランチにいない) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

dotfiles

個人用のdotfiles設定リポジトリです。 パッケージ管理と Homebrew 管理は別の nix-config リポジトリで行い、このリポジトリは主にシェルやアプリ設定を管理します。

セットアップ手順

1. リポジトリのクローン

git clone https://github.com/yourusername/dotfiles.git ~/dotfiles
cd ~/dotfiles

2. Nix / nix-darwin 設定の適用

cd ~/nix-config
darwin-rebuild switch --flake .#<nix-configで定義したホスト名>

3. dotfilesのシンボリックリンク作成

./install.sh

既存の設定ファイルは~/backupディレクトリにバックアップされます。

ファイル構成

  • install.sh: dotfilesのシンボリックリンク作成スクリプト
  • MANUAL_APPS.md: 手動インストールが必要なアプリケーション一覧
  • .config/: 各種アプリケーションの設定ファイル
  • .zshrc: Zshシェルの設定
  • .gitconfig: Git設定
  • .tmux.conf: tmux設定
  • .claude/: Claude Code の設定
  • .codex/: Codex の設定
Source 1 files
hooks/index.tsx 396 lines
1import 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