github-reporadar 0.1.10 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +121 -3
  2. package/dist-cli/cli.js +97 -17
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -3,6 +3,41 @@
3
3
  多数の個人リポジトリを並行開発する人のための「どこで何を作っていたか」想起ダッシュボード。
4
4
  GitHub の活動(commit / PR / issue)を期間指定で集約し、Web UI とターミナルの両方から確認できます。
5
5
 
6
+ ## 毎日の使い方(自分用の早見表)
7
+
8
+ 平日11:00-19:00は自動で動きます。詳しくは [docs/毎日の運用.md](docs/毎日の運用.md)。
9
+
10
+ 最初に1回だけ。
11
+
12
+ ```bash
13
+ echo "alias radar='~/dev/env/github-reporadar/ops/reporadar-workday.sh'" >> ~/.zshrc && source ~/.zshrc
14
+ ```
15
+
16
+ 以後打つのは3つだけ。
17
+
18
+ | 打つもの | いつ |
19
+ |---|---|
20
+ | `radar start` | 土日・深夜・時間外に見たいとき |
21
+ | `radar status` | 動いているか |
22
+ | `radar stop` | 止めるとき |
23
+
24
+ 見る場所は2つだけ。
25
+
26
+ | 見るもの | 場所 |
27
+ |---|---|
28
+ | 画面 | http://127.0.0.1:5177/ |
29
+ | うまくいかないとき | `tail -20 ~/.local/state/github-reporadar/workday.log` |
30
+
31
+ 新しいPCではこの3行。`gh auth login`が済んでいれば、設定ファイルも自動で作られます。
32
+
33
+ ```bash
34
+ git clone https://github.com/BoxPistols/github-reporadar.git ~/dev/env/github-reporadar
35
+ cd ~/dev/env/github-reporadar
36
+ ./ops/install.sh
37
+ ```
38
+
39
+ はじめての起動だけ、同期が終わるまで1分ほど画面が開きません。2回目からは1秒で開き、同期は裏で走ります。
40
+
6
41
  ## 機能
7
42
 
8
43
  Web UI はヘッダーで 3 モードを切り替えます。
@@ -101,10 +136,20 @@ npx github-reporadar brief --org example-company --days 7 --private
101
136
  ### 一日中開いておく(受信箱)
102
137
 
103
138
  ```bash
104
- npx github-reporadar --org example-company --watch 5m
139
+ npx github-reporadar --org example-company --watch 1m
105
140
  ```
106
141
 
107
- `--watch`を付けると、開いたまま5分ごとに同期し直します。同期のたびに前回との差分から「自分に起きたこと」を`events.json`(データdir)に足していきます。
142
+ `--watch`を付けると、開いたままその間隔でopen issue/PRを取り直します。同期のたびに前回との差分から「自分に起きたこと」を`events.json`(データdir)に足していきます。
143
+
144
+ 毎回はopen issue/PRとレビュー状態だけを取り、コミットは30分ごとにまとめて取ります。GitHubのAPIは認証済みで5000回/時なので、実測にもとづく目安はこうです。
145
+
146
+ | 間隔 | 1時間の消費(完全同期を含む) | 上限に対して |
147
+ |---|---|---|
148
+ | 30s | 4,430 | 89%。`gh`コマンドを併用する余裕が少ない |
149
+ | 1m | 2,390 | 48%。おすすめ |
150
+ | 5m | 700 | 14% |
151
+
152
+ 実測値は1回あたり、軽い同期がREST 34回で6秒、完全同期がREST 175回で13秒(42 repo、うち組織5)。repo数が増えると完全同期の側が伸びます。30秒未満は受け付けません。
108
153
 
109
154
  | 出来事 | 条件 |
110
155
  |---|---|
@@ -115,11 +160,84 @@ npx github-reporadar --org example-company --watch 5m
115
160
 
116
161
  初回の同期では何も出しません。溜まっていた分を一斉に出すと、新しく起きたことが埋もれるためです。画面のバッジと通知は次の版で入ります。
117
162
 
163
+ ### 平日の勤務時間だけ動かす(macOS)
164
+
165
+ launchdで平日11:00に起動し、19:00に停止します。土日は動きません。
166
+
167
+ ```bash
168
+ git clone https://github.com/BoxPistols/github-reporadar.git
169
+ cd github-reporadar
170
+ mkdir -p ~/.config/github-reporadar
171
+ cat > ~/.config/github-reporadar/workday.env <<'ENV'
172
+ RR_ORGS="example-company"
173
+ RR_WATCH=20m
174
+ RR_PORT=5177
175
+ ENV
176
+ ./ops/install.sh
177
+ ```
178
+
179
+ 設定ファイルはリポジトリの外(`~/.config/github-reporadar/workday.env`)に置きます。組織名を含むので、リポジトリには入れません。
180
+
181
+ | コマンド | 中身 |
182
+ |---|---|
183
+ | `./ops/install.sh` | launchdに登録。平日11:00起動 / 19:00停止 |
184
+ | `./ops/install.sh uninstall` | 登録を解除 |
185
+ | `./ops/reporadar-workday.sh start` / `stop` / `status` | 手で起動・停止・確認 |
186
+
187
+ ログは`~/.local/state/github-reporadar/workday.log`です。二重起動はしません。`gh`にログインしていなければ起動せず、理由をログに残します。
188
+
189
+ 20分間隔なら、1時間あたりのGitHub APIの消費は約313回で上限の6%です(軽い同期が約1.5回、完全同期が約1.5回)。8時間動かしても上限には触れません。
190
+
191
+ launchdは対話シェルの`PATH`を継がないので、`install.sh`が実行時の`PATH`をplistに焼き込みます。`node`や`gh`の場所を変えたら登録し直してください。
192
+
193
+ ### 毎日の運用
194
+
195
+ 登録したあとは、こちらから何もしなくても動きます。
196
+
197
+ | 時刻 | 起きること |
198
+ |---|---|
199
+ | 平日11:00 | 起動して完全同期を1回。以後20分ごとにopen issue/PRを取り直す |
200
+ | 20分ごと | 受信箱に差分が溜まる。ブラウザを開いていれば通知が出る |
201
+ | 30分ごと | コミットも含めた完全同期 |
202
+ | 平日19:00 | 停止 |
203
+ | 土日 | 動かない |
204
+
205
+ 自分でやることはこれだけです。
206
+
207
+ 1. 朝、http://127.0.0.1:5177/ を開く。ブックマークしておく
208
+ 2. フィルターバーの札で今日やることを決める(レビュー待ちの自分のPR、自分がレビューするPR、Draft、手を付けていないissue)
209
+ 3. 受信箱に未読が付いたら開く。番号を押すとGitHubへ飛ぶ。読み終えたら「すべて既読」
210
+ 4. 迷ったら壁打ちで「今日の作業順を決めたい」と聞く
211
+
212
+ 初回だけ、受信箱の「通知を許可」を1回押してください。ブラウザを閉じている間の出来事も`events.json`に溜まるので、翌朝に開けば読めます。
213
+
214
+ ### 様子がおかしいとき
215
+
216
+ | 症状 | 見るところ |
217
+ |---|---|
218
+ | 画面が開かない | `./ops/reporadar-workday.sh status`。動いていなければ`start` |
219
+ | 数字が古い | ログの末尾。`tail -20 ~/.local/state/github-reporadar/workday.log` |
220
+ | 受信箱が空のまま | 差分は前回の同期との比較で出る。初回同期の直後は必ず空。1回目の20分を待つ |
221
+ | 「gh にログインしていません」 | `gh auth login`。組織がSSOなら`gh auth refresh -h github.com -s read:org`も |
222
+ | 組織のrepoが出ない | 設定の`RR_ORGS`。`cat ~/.config/github-reporadar/workday.env` |
223
+ | 通知が来ない | ブラウザの通知許可。受信箱を開いて「通知を許可」が出ていないか |
224
+ | 起動しなくなった | `node`や`gh`の場所が変わった可能性。`./ops/install.sh`で登録し直す |
225
+
226
+ ### 更新とやめかた
227
+
228
+ ```bash
229
+ ./ops/reporadar-workday.sh restart # 新しい版に入れ替える(npxが@latestを取り直す)
230
+ ./ops/install.sh uninstall # 自動起動をやめる
231
+ ./ops/reporadar-workday.sh stop # いま動いているものを止める
232
+ ```
233
+
234
+ 新しい版は次の11:00の起動から自動で使われます。すぐ入れ替えたいときだけ`restart`します。
235
+
118
236
  ### 運用の方針
119
237
 
120
238
  | 方針 | 理由 |
121
239
  |---|---|
122
- | 「リアルタイム」はポーリング(`--watch`)で作る | GitHubのWebhookは受け口のサーバーが要り、ローカル完結と矛盾する。5分間隔なら1日で300回弱の同期で、レート制限(認証済み5000回/時)に十分収まる。60秒未満は受け付けない |
240
+ | 「リアルタイム」はポーリング(`--watch`)で作る | GitHubのWebhookは受け口のサーバーが要り、ローカル完結と矛盾する。毎回の同期をopen issue/PRだけに絞ってREST 34回にしたので、1分間隔でも上限の半分で収まる(実測)。コミットは30分ごと |
123
241
  | 出来事は前回との差分から作る | GitHubの通知APIは組織の全件が混ざり、自分のPRに関することだけを取り出しにくい。前回の`issues.json`と比べるだけなら追加のAPIも権限も要らない |
124
242
  | 組織はローカルだけ | 上の「前提と境界」のとおり。公開版に足すなら組織管理者への説明と明示同意の画面が先 |
125
243
  | 通知はブラウザのNotification API(次の版) | ページを開いている間に出る。OSの通知センターまで欲しい場合はCLI側から出す案があるが、まずはブラウザで足りるかを見る |
package/dist-cli/cli.js CHANGED
@@ -366,6 +366,33 @@ async function listOpenIssues(token, repo, login) {
366
366
  }
367
367
  return items;
368
368
  }
369
+ async function fetchIssuesOnly(token, fullNames, opts) {
370
+ const limit = createLimiter(CONCURRENCY);
371
+ const counts = await fetchOpenCounts(token, fullNames, limit);
372
+ const targets = fullNames.filter((n) => {
373
+ const c = counts.get(n);
374
+ return c != null && c.pr + c.issue > 0;
375
+ });
376
+ const lists = await Promise.all(
377
+ targets.map((n) => {
378
+ const [owner] = n.split("/");
379
+ const login = owner.toLowerCase() === opts.ownLogin.toLowerCase() ? null : opts.ownLogin;
380
+ return limit(() => listOpenIssues(token, { full_name: n }, login));
381
+ })
382
+ );
383
+ const flat = lists.flat();
384
+ const prMeta = await fetchPrMeta(
385
+ token,
386
+ flat.filter((it) => it.type === "pr").map((it) => ({ repo: it.repo, number: it.number })),
387
+ limit
388
+ );
389
+ const issues = flat.map((it) => {
390
+ if (it.type !== "pr") return it;
391
+ const meta = prMeta.get(`${it.repo}#${it.number}`);
392
+ return meta ? { ...it, ...meta } : it;
393
+ });
394
+ return { issues, counts };
395
+ }
369
396
  function createProgress(repoTotal, issueTotal) {
370
397
  const enabled = process.stdout.isTTY === true;
371
398
  let repos = 0;
@@ -505,6 +532,16 @@ const DEFAULT_WINDOW_DAYS = 90;
505
532
  async function runSync(opts) {
506
533
  const windowDays = opts.days ?? DEFAULT_WINDOW_DAYS;
507
534
  const log = opts.log ?? ((line) => console.log(line));
535
+ const readJson = (name) => {
536
+ const path = join(opts.dataDir, name);
537
+ if (!existsSync(path)) return null;
538
+ try {
539
+ return JSON.parse(readFileSync(path, "utf8"));
540
+ } catch {
541
+ return null;
542
+ }
543
+ };
544
+ if (opts.mode === "light") return runLightSync(opts, log, readJson);
508
545
  if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365) {
509
546
  throw new Error(`--days は 1〜365 で指定してください: ${windowDays}`);
510
547
  }
@@ -516,15 +553,6 @@ async function runSync(opts) {
516
553
  if (orgs.length > 0) log(`組織も取得: ${orgs.join(", ")}(自分がauthorのコミットだけ数える)`);
517
554
  const { login, repos, activity, issues } = await fetchActivity(token, from, { includePrivate: true, orgs });
518
555
  mkdirSync(opts.dataDir, { recursive: true });
519
- const readJson = (name) => {
520
- const path = join(opts.dataDir, name);
521
- if (!existsSync(path)) return null;
522
- try {
523
- return JSON.parse(readFileSync(path, "utf8"));
524
- } catch {
525
- return null;
526
- }
527
- };
528
556
  const prevIssues = readJson("issues.json");
529
557
  const prevRepos = readJson("repos.json");
530
558
  const fresh = diffEvents(prevIssues, issues, login, to.toISOString(), {
@@ -543,6 +571,34 @@ async function runSync(opts) {
543
571
  log(`OK: ${repos.length} repos / ${totalCommits} commits / ${issues.length} open issues+PRs → ${opts.dataDir}`);
544
572
  return { login, repos: repos.length, commits: totalCommits, issues: issues.length, events: fresh.length };
545
573
  }
574
+ async function runLightSync(opts, log, readJson) {
575
+ const prevRepos = readJson("repos.json");
576
+ if (!prevRepos || prevRepos.length === 0) {
577
+ log("前回の同期が無いので完全同期します");
578
+ return runSync({ ...opts, mode: "full" });
579
+ }
580
+ const token = getToken();
581
+ const login = readJson("meta.json")?.login;
582
+ if (!login) return runSync({ ...opts, mode: "full" });
583
+ const names = prevRepos.map((r) => r.nameWithOwner);
584
+ const { issues, counts } = await fetchIssuesOnly(token, names, { ownLogin: login });
585
+ const to = /* @__PURE__ */ new Date();
586
+ const prevIssues = readJson("issues.json");
587
+ const repoSet = new Set(names);
588
+ const fresh = diffEvents(prevIssues, issues, login, to.toISOString(), { prevRepos: repoSet, nextRepos: repoSet });
589
+ const events = mergeEvents(readJson("events.json") ?? [], fresh);
590
+ const repos = prevRepos.map((r) => {
591
+ const c = counts.get(r.nameWithOwner);
592
+ return c ? { ...r, openPrCount: c.pr, openIssueCount: c.issue } : r;
593
+ });
594
+ const meta = readJson("meta.json");
595
+ writeFileSync(join(opts.dataDir, "issues.json"), JSON.stringify(issues, null, 2));
596
+ writeFileSync(join(opts.dataDir, "events.json"), JSON.stringify(events, null, 2));
597
+ writeFileSync(join(opts.dataDir, "repos.json"), JSON.stringify(repos, null, 2));
598
+ if (meta) writeFileSync(join(opts.dataDir, "meta.json"), JSON.stringify({ ...meta, lastSyncAt: to.toISOString() }, null, 2));
599
+ log(`更新: open ${issues.length}件${fresh.length > 0 ? ` / 受信箱に${fresh.length}件` : ""}`);
600
+ return { login, repos: repos.length, commits: 0, issues: issues.length, events: fresh.length };
601
+ }
546
602
  const isMain = process.argv[1]?.endsWith("sync.ts") || process.argv[1]?.endsWith("sync.js");
547
603
  if (isMain) {
548
604
  const daysArg = process.argv.indexOf("--days");
@@ -615,7 +671,7 @@ function parseArgs(argv) {
615
671
  const m = v && !v.startsWith("--") ? /^(\d+)(s|m|h)$/.exec(v) : null;
616
672
  if (!m) throw new RangeError(`--watchは 5m / 90s / 1h のように指定してください(受け取った値: ${v ?? "無し"})`);
617
673
  const sec = Number(m[1]) * (m[2] === "h" ? 3600 : m[2] === "m" ? 60 : 1);
618
- if (sec < 60) throw new RangeError(`--watchは60秒以上にしてください(GitHubのレート制限を守る)。受け取った値: ${v}`);
674
+ if (sec < 30) throw new RangeError(`--watchは30秒以上にしてください(GitHubのレート制限を守る)。受け取った値: ${v}`);
619
675
  out.watchSeconds = sec;
620
676
  break;
621
677
  }
@@ -660,7 +716,7 @@ const USAGE = [
660
716
  row("--port N", "待ち受けポート(既定5177。使用中なら次を探す)"),
661
717
  row("--no-open", "ブラウザを開かない"),
662
718
  row("--no-sync", "同期せずに手元のデータで開く"),
663
- row("--watch 5m", "開いたまま、この間隔で同期し直す(60秒以上)。受信箱に自分に起きたことが溜まる"),
719
+ row("--watch 1m", "開いたまま、この間隔でopen issue/PRを取り直す(30秒以上)。受信箱に自分に起きたことが溜まる"),
664
720
  row("--org NAME", "組織のrepoも取得する(繰り返し可)。組織では自分がauthorのコミットと、自分が作ったか担当のissue/PRだけ数える"),
665
721
  row("--data-dir DIR", "データの置き場(既定: $XDG_CACHE_HOME か ~/.cache の github-reporadar/data)"),
666
722
  row("--sort KEY", "brief の並び: active | recent | stale"),
@@ -795,17 +851,22 @@ async function main() {
795
851
  return;
796
852
  }
797
853
  const dataDir = args.dataDir ?? defaultDataDir(process.env, homedir());
798
- if (args.command === "sync" || args.command === "serve" && args.sync) {
854
+ if (args.command === "sync") {
799
855
  await runSync({ dataDir, days: args.days, orgs: args.orgs });
856
+ return;
800
857
  }
801
- if (args.command === "sync") return;
802
858
  if (args.command === "brief") {
803
859
  runBrief({ dataDir, days: args.days, sort: args.sort, limit: args.limit, includePrivate: args.includePrivate });
804
860
  return;
805
861
  }
806
- if (!existsSync(join(dataDir, "meta.json"))) {
807
- throw new Error(`データがありません: ${dataDir}
862
+ const hasData = existsSync(join(dataDir, "meta.json"));
863
+ if (!hasData) {
864
+ if (!args.sync) {
865
+ throw new Error(`データがありません: ${dataDir}
808
866
  先に \`github-reporadar sync\`を実行するか、--no-syncを外してください`);
867
+ }
868
+ console.log("はじめての同期です。1分ほどかかります");
869
+ await runSync({ dataDir, days: args.days, orgs: args.orgs });
809
870
  }
810
871
  const dist = distDir();
811
872
  if (!existsSync(join(dist, "index.html"))) {
@@ -817,14 +878,33 @@ async function main() {
817
878
  console.log(`GitHub RepoRadar: ${url} (データ: ${dataDir})`);
818
879
  console.log("止めるにはCtrl+C");
819
880
  if (args.open) openBrowser(url);
881
+ if (hasData && args.sync) {
882
+ console.log("裏で同期しています");
883
+ runSync({ dataDir, days: args.days, orgs: args.orgs, log: (line) => console.log(`[sync] ${line}`) }).catch(
884
+ (err) => console.error(`同期に失敗: ${err instanceof Error ? err.message : String(err)}`)
885
+ );
886
+ }
820
887
  let timer = null;
821
888
  if (args.watchSeconds) {
822
- console.log(`${args.watchSeconds}秒ごとに同期します`);
889
+ const FULL_EVERY_MS = 30 * 60 * 1e3;
890
+ console.log(`${args.watchSeconds}秒ごとにopen issue/PRを取り直します(コミットは30分ごと)`);
891
+ if (args.watchSeconds < 60) {
892
+ console.log("60秒未満はGitHubの制限に対する余裕が少なくなります(1時間あたり上限の約9割)");
893
+ }
823
894
  let busy = false;
895
+ let lastFull = Date.now();
824
896
  timer = setInterval(() => {
825
897
  if (busy) return;
826
898
  busy = true;
827
- runSync({ dataDir, days: args.days, orgs: args.orgs, log: (line) => console.log(`[watch] ${line}`) }).catch((err) => console.error(`[watch] 同期に失敗: ${err instanceof Error ? err.message : String(err)}`)).finally(() => {
899
+ const full = Date.now() - lastFull >= FULL_EVERY_MS;
900
+ if (full) lastFull = Date.now();
901
+ runSync({
902
+ dataDir,
903
+ days: args.days,
904
+ orgs: args.orgs,
905
+ mode: full ? "full" : "light",
906
+ log: (line) => console.log(`[watch] ${line}`)
907
+ }).catch((err) => console.error(`[watch] 同期に失敗: ${err instanceof Error ? err.message : String(err)}`)).finally(() => {
828
908
  busy = false;
829
909
  });
830
910
  }, args.watchSeconds * 1e3);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-reporadar",
3
- "version": "0.1.10",
3
+ "version": "0.2.1",
4
4
  "description": "自分のGitHub活動を一晩ぶん眺める計器盤。npx github-reporadarでローカルに開く",
5
5
  "license": "MIT",
6
6
  "bin": {