github-reporadar 0.1.10 → 0.2.0

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.
package/README.md CHANGED
@@ -101,10 +101,20 @@ npx github-reporadar brief --org example-company --days 7 --private
101
101
  ### 一日中開いておく(受信箱)
102
102
 
103
103
  ```bash
104
- npx github-reporadar --org example-company --watch 5m
104
+ npx github-reporadar --org example-company --watch 1m
105
105
  ```
106
106
 
107
- `--watch`を付けると、開いたまま5分ごとに同期し直します。同期のたびに前回との差分から「自分に起きたこと」を`events.json`(データdir)に足していきます。
107
+ `--watch`を付けると、開いたままその間隔でopen issue/PRを取り直します。同期のたびに前回との差分から「自分に起きたこと」を`events.json`(データdir)に足していきます。
108
+
109
+ 毎回はopen issue/PRとレビュー状態だけを取り、コミットは30分ごとにまとめて取ります。GitHubのAPIは認証済みで5000回/時なので、実測にもとづく目安はこうです。
110
+
111
+ | 間隔 | 1時間の消費(完全同期を含む) | 上限に対して |
112
+ |---|---|---|
113
+ | 30s | 4,430 | 89%。`gh`コマンドを併用する余裕が少ない |
114
+ | 1m | 2,390 | 48%。おすすめ |
115
+ | 5m | 700 | 14% |
116
+
117
+ 実測値は1回あたり、軽い同期がREST 34回で6秒、完全同期がREST 175回で13秒(42 repo、うち組織5)。repo数が増えると完全同期の側が伸びます。30秒未満は受け付けません。
108
118
 
109
119
  | 出来事 | 条件 |
110
120
  |---|---|
@@ -115,11 +125,84 @@ npx github-reporadar --org example-company --watch 5m
115
125
 
116
126
  初回の同期では何も出しません。溜まっていた分を一斉に出すと、新しく起きたことが埋もれるためです。画面のバッジと通知は次の版で入ります。
117
127
 
128
+ ### 平日の勤務時間だけ動かす(macOS)
129
+
130
+ launchdで平日11:00に起動し、19:00に停止します。土日は動きません。
131
+
132
+ ```bash
133
+ git clone https://github.com/BoxPistols/github-reporadar.git
134
+ cd github-reporadar
135
+ mkdir -p ~/.config/github-reporadar
136
+ cat > ~/.config/github-reporadar/workday.env <<'ENV'
137
+ RR_ORGS="example-company"
138
+ RR_WATCH=20m
139
+ RR_PORT=5177
140
+ ENV
141
+ ./ops/install.sh
142
+ ```
143
+
144
+ 設定ファイルはリポジトリの外(`~/.config/github-reporadar/workday.env`)に置きます。組織名を含むので、リポジトリには入れません。
145
+
146
+ | コマンド | 中身 |
147
+ |---|---|
148
+ | `./ops/install.sh` | launchdに登録。平日11:00起動 / 19:00停止 |
149
+ | `./ops/install.sh uninstall` | 登録を解除 |
150
+ | `./ops/reporadar-workday.sh start` / `stop` / `status` | 手で起動・停止・確認 |
151
+
152
+ ログは`~/.local/state/github-reporadar/workday.log`です。二重起動はしません。`gh`にログインしていなければ起動せず、理由をログに残します。
153
+
154
+ 20分間隔なら、1時間あたりのGitHub APIの消費は約313回で上限の6%です(軽い同期が約1.5回、完全同期が約1.5回)。8時間動かしても上限には触れません。
155
+
156
+ launchdは対話シェルの`PATH`を継がないので、`install.sh`が実行時の`PATH`をplistに焼き込みます。`node`や`gh`の場所を変えたら登録し直してください。
157
+
158
+ ### 毎日の運用
159
+
160
+ 登録したあとは、こちらから何もしなくても動きます。
161
+
162
+ | 時刻 | 起きること |
163
+ |---|---|
164
+ | 平日11:00 | 起動して完全同期を1回。以後20分ごとにopen issue/PRを取り直す |
165
+ | 20分ごと | 受信箱に差分が溜まる。ブラウザを開いていれば通知が出る |
166
+ | 30分ごと | コミットも含めた完全同期 |
167
+ | 平日19:00 | 停止 |
168
+ | 土日 | 動かない |
169
+
170
+ 自分でやることはこれだけです。
171
+
172
+ 1. 朝、http://127.0.0.1:5177/ を開く。ブックマークしておく
173
+ 2. フィルターバーの札で今日やることを決める(レビュー待ちの自分のPR、自分がレビューするPR、Draft、手を付けていないissue)
174
+ 3. 受信箱に未読が付いたら開く。番号を押すとGitHubへ飛ぶ。読み終えたら「すべて既読」
175
+ 4. 迷ったら壁打ちで「今日の作業順を決めたい」と聞く
176
+
177
+ 初回だけ、受信箱の「通知を許可」を1回押してください。ブラウザを閉じている間の出来事も`events.json`に溜まるので、翌朝に開けば読めます。
178
+
179
+ ### 様子がおかしいとき
180
+
181
+ | 症状 | 見るところ |
182
+ |---|---|
183
+ | 画面が開かない | `./ops/reporadar-workday.sh status`。動いていなければ`start` |
184
+ | 数字が古い | ログの末尾。`tail -20 ~/.local/state/github-reporadar/workday.log` |
185
+ | 受信箱が空のまま | 差分は前回の同期との比較で出る。初回同期の直後は必ず空。1回目の20分を待つ |
186
+ | 「gh にログインしていません」 | `gh auth login`。組織がSSOなら`gh auth refresh -h github.com -s read:org`も |
187
+ | 組織のrepoが出ない | 設定の`RR_ORGS`。`cat ~/.config/github-reporadar/workday.env` |
188
+ | 通知が来ない | ブラウザの通知許可。受信箱を開いて「通知を許可」が出ていないか |
189
+ | 起動しなくなった | `node`や`gh`の場所が変わった可能性。`./ops/install.sh`で登録し直す |
190
+
191
+ ### 更新とやめかた
192
+
193
+ ```bash
194
+ ./ops/reporadar-workday.sh restart # 新しい版に入れ替える(npxが@latestを取り直す)
195
+ ./ops/install.sh uninstall # 自動起動をやめる
196
+ ./ops/reporadar-workday.sh stop # いま動いているものを止める
197
+ ```
198
+
199
+ 新しい版は次の11:00の起動から自動で使われます。すぐ入れ替えたいときだけ`restart`します。
200
+
118
201
  ### 運用の方針
119
202
 
120
203
  | 方針 | 理由 |
121
204
  |---|---|
122
- | 「リアルタイム」はポーリング(`--watch`)で作る | GitHubのWebhookは受け口のサーバーが要り、ローカル完結と矛盾する。5分間隔なら1日で300回弱の同期で、レート制限(認証済み5000回/時)に十分収まる。60秒未満は受け付けない |
205
+ | 「リアルタイム」はポーリング(`--watch`)で作る | GitHubのWebhookは受け口のサーバーが要り、ローカル完結と矛盾する。毎回の同期をopen issue/PRだけに絞ってREST 34回にしたので、1分間隔でも上限の半分で収まる(実測)。コミットは30分ごと |
123
206
  | 出来事は前回との差分から作る | GitHubの通知APIは組織の全件が混ざり、自分のPRに関することだけを取り出しにくい。前回の`issues.json`と比べるだけなら追加のAPIも権限も要らない |
124
207
  | 組織はローカルだけ | 上の「前提と境界」のとおり。公開版に足すなら組織管理者への説明と明示同意の画面が先 |
125
208
  | 通知はブラウザの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"),
@@ -819,12 +875,25 @@ async function main() {
819
875
  if (args.open) openBrowser(url);
820
876
  let timer = null;
821
877
  if (args.watchSeconds) {
822
- console.log(`${args.watchSeconds}秒ごとに同期します`);
878
+ const FULL_EVERY_MS = 30 * 60 * 1e3;
879
+ console.log(`${args.watchSeconds}秒ごとにopen issue/PRを取り直します(コミットは30分ごと)`);
880
+ if (args.watchSeconds < 60) {
881
+ console.log("60秒未満はGitHubの制限に対する余裕が少なくなります(1時間あたり上限の約9割)");
882
+ }
823
883
  let busy = false;
884
+ let lastFull = Date.now();
824
885
  timer = setInterval(() => {
825
886
  if (busy) return;
826
887
  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(() => {
888
+ const full = Date.now() - lastFull >= FULL_EVERY_MS;
889
+ if (full) lastFull = Date.now();
890
+ runSync({
891
+ dataDir,
892
+ days: args.days,
893
+ orgs: args.orgs,
894
+ mode: full ? "full" : "light",
895
+ log: (line) => console.log(`[watch] ${line}`)
896
+ }).catch((err) => console.error(`[watch] 同期に失敗: ${err instanceof Error ? err.message : String(err)}`)).finally(() => {
828
897
  busy = false;
829
898
  });
830
899
  }, 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.0",
4
4
  "description": "自分のGitHub活動を一晩ぶん眺める計器盤。npx github-reporadarでローカルに開く",
5
5
  "license": "MIT",
6
6
  "bin": {