github-reporadar 0.6.0 → 0.8.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/dist/index.html CHANGED
@@ -16,8 +16,8 @@
16
16
  href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"
17
17
  rel="stylesheet"
18
18
  />
19
- <script type="module" crossorigin src="/assets/index-B3RBEh9j.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-R2XOh3Es.css">
19
+ <script type="module" crossorigin src="/assets/index-DmB2CQfF.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-DQkwmLip.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/dist-cli/cli.js CHANGED
@@ -653,6 +653,10 @@ function buildCql(spaces = []) {
653
653
  const scope = spaces.length > 0 ? `space in (${spaces.join(",")}) AND ` : "";
654
654
  return `${scope}contributor = currentUser() AND type = page ORDER BY lastmodified DESC`;
655
655
  }
656
+ function buildFolderCql(folders) {
657
+ if (folders.length === 0) return "";
658
+ return `ancestor in (${folders.join(",")}) AND type = page ORDER BY lastmodified DESC`;
659
+ }
656
660
  const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", "#39": "'" };
657
661
  const decodeEntities = (t) => t.replace(/&([a-z]+|#\d+);/gi, (m, name) => ENTITIES[name.toLowerCase()] ?? m);
658
662
  function stripMarkup(source) {
@@ -690,7 +694,7 @@ function extractRepoRefs(text, knownRepos) {
690
694
  }
691
695
  return found;
692
696
  }
693
- function mapPage(raw, site, knownRepos) {
697
+ function mapPage(raw, site, knownRepos, origin = { mine: true, watched: false }) {
694
698
  const body = raw.body?.storage?.value;
695
699
  const text = `${raw.title} ${linkTargets(body)} ${stripMarkup(body)}`;
696
700
  const webui = raw._links?.webui ?? "";
@@ -702,9 +706,22 @@ function mapPage(raw, site, knownRepos) {
702
706
  url: webui ? `https://${site}/wiki${webui}` : `https://${site}/wiki/pages/viewpage.action?pageId=${raw.id}`,
703
707
  lastModified: raw.version?.when ?? "",
704
708
  lastModifiedBy: raw.version?.by?.accountId ?? "",
709
+ createdAt: raw.history?.createdDate ?? "",
710
+ mine: origin.mine,
711
+ watched: origin.watched,
705
712
  repos: extractRepoRefs(text, knownRepos)
706
713
  };
707
714
  }
715
+ function mergePages(groups) {
716
+ const byId = /* @__PURE__ */ new Map();
717
+ for (const group of groups) {
718
+ for (const page of group) {
719
+ const seen = byId.get(page.id);
720
+ byId.set(page.id, seen ? { ...seen, mine: seen.mine || page.mine, watched: seen.watched || page.watched } : page);
721
+ }
722
+ }
723
+ return [...byId.values()];
724
+ }
708
725
  const DAY = 24 * 60 * 60 * 1e3;
709
726
  function driftingPages(pages, repos, minDays = 1) {
710
727
  const pushedAt = new Map(repos.map((r) => [r.nameWithOwner, r.pushedAt]));
@@ -730,42 +747,56 @@ function driftingPages(pages, repos, minDays = 1) {
730
747
  });
731
748
  }
732
749
  }
733
- return out.sort((a, b) => b.daysBehind - a.daysBehind || a.title.localeCompare(b.title));
750
+ return out.sort((a, b) => a.lastModified < b.lastModified ? 1 : a.lastModified > b.lastModified ? -1 : a.title.localeCompare(b.title));
734
751
  }
735
- const EXPAND = "space,version,body.storage";
752
+ const EXPAND = "space,version,history,body.storage";
736
753
  const PAGE_SIZE = 50;
737
- const MAX_TOTAL = 200;
754
+ const MAX_ROUNDS = 8;
755
+ const accountIdCache = /* @__PURE__ */ new Map();
738
756
  async function fetchMyAccountId(cfg) {
757
+ const key2 = `${cfg.site}/${cfg.email}`;
758
+ const cached = accountIdCache.get(key2);
759
+ if (cached !== void 0) return cached;
739
760
  const token = getAtlassianToken(cfg.email);
740
761
  if (!token) return null;
741
762
  try {
742
763
  const res = await fetch(`https://${cfg.site}/wiki/rest/api/user/current`, { headers: basicAuthHeaders(cfg.email, token) });
743
764
  if (!res.ok) return null;
744
765
  const json = await res.json();
745
- return json.accountId ?? null;
766
+ if (!json.accountId) return null;
767
+ accountIdCache.set(key2, json.accountId);
768
+ return json.accountId;
746
769
  } catch {
747
770
  return null;
748
771
  }
749
772
  }
750
- async function fetchConfluencePages(cfg, knownRepos) {
751
- if (!cfg?.site || !cfg.email) return [];
752
- const token = getAtlassianToken(cfg.email);
753
- if (!token) return [];
754
- const headers = basicAuthHeaders(cfg.email, token);
755
- const cql = buildCql(cfg.spaces ?? []);
773
+ function nextPath(link) {
774
+ return link && link.startsWith("/rest/") ? link : null;
775
+ }
776
+ async function searchByCql(cfg, cql, headers, knownRepos, origin) {
777
+ const params = new URLSearchParams({ cql, expand: EXPAND, limit: String(PAGE_SIZE) });
778
+ let path = `/rest/api/content/search?${params}`;
756
779
  const out = [];
757
- for (let start = 0; start < MAX_TOTAL; start += PAGE_SIZE) {
758
- const params = new URLSearchParams({ cql, expand: EXPAND, limit: String(PAGE_SIZE), start: String(start) });
759
- const res = await fetch(`https://${cfg.site}/wiki/rest/api/content/search?${params}`, { headers });
780
+ for (let round = 0; round < MAX_ROUNDS && path; round++) {
781
+ const res = await fetch(`https://${cfg.site}/wiki${path}`, { headers });
760
782
  if (!res.ok)
761
783
  throw new Error(`Confluence /content/search HTTP ${res.status}: ${(await res.text()).slice(0, 200)}${authHint(res.status, cfg.email)}`);
762
784
  const json = await res.json();
763
- const batch = json.results ?? [];
764
- for (const raw of batch) if (raw?.id) out.push(mapPage(raw, cfg.site, knownRepos));
765
- if (batch.length < PAGE_SIZE) break;
785
+ for (const raw of json.results ?? []) if (raw?.id) out.push(mapPage(raw, cfg.site, knownRepos, origin));
786
+ path = nextPath(json._links?.next);
766
787
  }
767
788
  return out;
768
789
  }
790
+ async function fetchConfluencePages(cfg, knownRepos) {
791
+ if (!cfg?.site || !cfg.email) return [];
792
+ const token = getAtlassianToken(cfg.email);
793
+ if (!token) return [];
794
+ const headers = basicAuthHeaders(cfg.email, token);
795
+ const groups = [await searchByCql(cfg, buildCql(cfg.spaces ?? []), headers, knownRepos, { mine: true, watched: false })];
796
+ const folderCql = buildFolderCql(cfg.folders ?? []);
797
+ if (folderCql) groups.push(await searchByCql(cfg, folderCql, headers, knownRepos, { mine: false, watched: true }));
798
+ return mergePages(groups);
799
+ }
769
800
  function buildSlackQuery(repo) {
770
801
  return `"${repo}"`;
771
802
  }
@@ -888,24 +919,22 @@ async function fetchMyMentions(max = COUNT) {
888
919
  return [];
889
920
  }
890
921
  }
891
- function diffWikiPages(prev, next, now, myAccountId) {
922
+ function diffWikiPages(prev, next, now, opts = {}) {
892
923
  if (prev === null) return [];
893
924
  const before = new Map(prev.map((p) => [p.id, p]));
925
+ const since = opts.since ?? null;
894
926
  const out = [];
927
+ const base = (page) => ({ at: now, repo: page.space, number: 0, title: page.title, url: page.url, source: "wiki" });
895
928
  for (const page of next) {
896
929
  const b = before.get(page.id);
897
- if (!b || !page.lastModified || page.lastModified <= b.lastModified) continue;
898
- if (myAccountId && page.lastModifiedBy === myAccountId) continue;
899
- out.push({
900
- id: `wiki:${page.id}:${page.lastModified}`,
901
- at: now,
902
- kind: "wiki_updated",
903
- repo: page.space,
904
- number: 0,
905
- title: page.title,
906
- url: page.url,
907
- source: "wiki"
908
- });
930
+ if (!b) {
931
+ if (!since || !page.createdAt || page.createdAt <= since || page.byMe) continue;
932
+ out.push({ id: `wiki:${page.id}:added`, kind: "wiki_added", ...base(page) });
933
+ continue;
934
+ }
935
+ if (!page.lastModified || page.lastModified <= b.lastModified) continue;
936
+ if (page.byMe) continue;
937
+ out.push({ id: `wiki:${page.id}:${page.lastModified}`, kind: "wiki_updated", ...base(page) });
909
938
  }
910
939
  return out;
911
940
  }
@@ -1052,6 +1081,7 @@ async function runSync(opts) {
1052
1081
  mkdirSync(opts.dataDir, { recursive: true });
1053
1082
  const prevIssues = readJson("issues.json");
1054
1083
  const prevRepos = readJson("repos.json");
1084
+ const prevSyncAt = readJson("meta.json")?.lastSyncAt ?? null;
1055
1085
  const lastComments = await fetchLastComments(token, commentIncreased(prevIssues, issues), MAX_COMMENT_LOOKUPS);
1056
1086
  const fresh = diffEvents(prevIssues, issues, login, to.toISOString(), {
1057
1087
  prevRepos: prevRepos ? new Set(prevRepos.map((r) => r.nameWithOwner)) : void 0,
@@ -1072,7 +1102,7 @@ async function runSync(opts) {
1072
1102
  writeFileSync(join(opts.dataDir, "refs.json"), JSON.stringify(refStates, null, 2));
1073
1103
  warnMissingAtlassianToken(opts, log);
1074
1104
  await writeJira(opts, log);
1075
- await writeConfluence(opts, repos, to.toISOString(), readJson, log);
1105
+ await writeConfluence(opts, repos, to.toISOString(), readJson, log, prevSyncAt);
1076
1106
  await writeSlack(opts, to.toISOString(), readJson, log);
1077
1107
  await writeSlackLinks(opts, issues, login, log);
1078
1108
  const totalCommits = activity.reduce((sum, a) => sum + a.commits, 0);
@@ -1107,7 +1137,7 @@ async function runLightSync(opts, log, readJson) {
1107
1137
  writeFileSync(join(opts.dataDir, "repos.json"), JSON.stringify(repos, null, 2));
1108
1138
  if (meta) writeFileSync(join(opts.dataDir, "meta.json"), JSON.stringify({ ...meta, lastSyncAt: to.toISOString() }, null, 2));
1109
1139
  await writeJira(opts, log);
1110
- await writeConfluence(opts, repos, to.toISOString(), readJson, log);
1140
+ await writeConfluence(opts, repos, to.toISOString(), readJson, log, meta?.lastSyncAt ?? null);
1111
1141
  await writeSlack(opts, to.toISOString(), readJson, log);
1112
1142
  log(`更新: open ${issues.length}件${fresh.length > 0 ? ` / 受信箱に${fresh.length}件` : ""}`);
1113
1143
  return { login, repos: repos.length, commits: 0, issues: issues.length, events: fresh.length };
@@ -1163,11 +1193,16 @@ function warnMissingAtlassianToken(opts, log) {
1163
1193
  log(` security add-generic-password -s github-reporadar-atlassian -a "${cfg.email}" -w "<APIトークン>"`);
1164
1194
  log(` トークンは https://id.atlassian.com/manage-profile/security/api-tokens で作れます`);
1165
1195
  }
1166
- async function writeConfluence(opts, repos, now, readJson, log) {
1196
+ async function writeConfluence(opts, repos, now, readJson, log, since) {
1167
1197
  if (!opts.confluence) return;
1168
1198
  try {
1169
1199
  const pages = await fetchConfluencePages(opts.confluence, repos.map((r) => r.nameWithOwner));
1170
- const drifts = driftingPages(pages, repos.map((r) => ({ nameWithOwner: r.nameWithOwner, pushedAt: r.pushedAt })));
1200
+ const myAccountId = pages.length === 0 ? null : await fetchMyAccountId(opts.confluence);
1201
+ if (pages.length > 0 && myAccountId === null) {
1202
+ log(`Confluence: 自分のaccountIdを取れませんでした。誰が更新したかは「不明」として扱います`);
1203
+ }
1204
+ const mine = pages.filter((p) => p.mine);
1205
+ const drifts = driftingPages(mine, repos.map((r) => ({ nameWithOwner: r.nameWithOwner, pushedAt: r.pushedAt })));
1171
1206
  writeFileSync(join(opts.dataDir, "confluence.json"), JSON.stringify(drifts, null, 2));
1172
1207
  if (drifts.length > 0) log(`Confluence: 要確認${drifts.length}件`);
1173
1208
  const refs = pages.map((p) => ({
@@ -1176,16 +1211,19 @@ async function writeConfluence(opts, repos, now, readJson, log) {
1176
1211
  space: p.space,
1177
1212
  url: p.url,
1178
1213
  lastModified: p.lastModified,
1179
- lastModifiedBy: p.lastModifiedBy
1214
+ lastModifiedBy: p.lastModifiedBy,
1215
+ byMe: myAccountId === null || p.lastModifiedBy === "" ? void 0 : p.lastModifiedBy === myAccountId,
1216
+ createdAt: p.createdAt,
1217
+ watched: p.watched
1180
1218
  }));
1181
1219
  const prevPages = readJson("confluence-pages.json");
1182
- const myAccountId = prevPages === null ? null : await fetchMyAccountId(opts.confluence);
1183
- const fresh = diffWikiPages(prevPages, refs, now, myAccountId);
1220
+ const fresh = diffWikiPages(prevPages, refs, now, { since });
1184
1221
  writeFileSync(join(opts.dataDir, "confluence-pages.json"), JSON.stringify(refs, null, 2));
1185
1222
  if (fresh.length > 0) {
1186
1223
  const events = mergeEvents(readJson("events.json") ?? [], fresh);
1187
1224
  writeFileSync(join(opts.dataDir, "events.json"), JSON.stringify(events, null, 2));
1188
- log(`Confluence: 更新${fresh.length}件を受信箱へ`);
1225
+ const added = fresh.filter((e) => e.kind === "wiki_added").length;
1226
+ log(`Confluence: ${added > 0 ? `追加${added}件 / ` : ""}更新${fresh.length - added}件を受信箱へ`);
1189
1227
  }
1190
1228
  } catch (err) {
1191
1229
  log(`Confluenceの取得に失敗: ${err instanceof Error ? err.message : String(err)}`);
@@ -1218,8 +1256,16 @@ const DEFAULTS = {
1218
1256
  atlassianSite: null,
1219
1257
  atlassianEmail: null,
1220
1258
  jiraProjects: [],
1221
- confluenceSpaces: []
1259
+ confluenceSpaces: [],
1260
+ confluenceFolders: []
1222
1261
  };
1262
+ function parseFolderId(raw) {
1263
+ const trimmed = raw.trim();
1264
+ if (/^\d+$/.test(trimmed)) return trimmed;
1265
+ const m = /\/folder\/(\d+)/.exec(trimmed);
1266
+ if (m) return m[1];
1267
+ throw new RangeError(`--confluence-folderはフォルダのID(数字)か、フォルダのURLで指定してください(受け取った値: ${raw})`);
1268
+ }
1223
1269
  function intIn(flag, raw, min, max) {
1224
1270
  if (raw === void 0 || raw.startsWith("--")) throw new RangeError(`${flag} には値が要ります`);
1225
1271
  const n = Number(raw);
@@ -1229,7 +1275,7 @@ function intIn(flag, raw, min, max) {
1229
1275
  return n;
1230
1276
  }
1231
1277
  function parseArgs(argv) {
1232
- const out = { ...DEFAULTS, orgs: [], jiraProjects: [], confluenceSpaces: [] };
1278
+ const out = { ...DEFAULTS, orgs: [], jiraProjects: [], confluenceSpaces: [], confluenceFolders: [] };
1233
1279
  let command = null;
1234
1280
  for (let i = 0; i < argv.length; i++) {
1235
1281
  const a = argv[i];
@@ -1280,6 +1326,13 @@ function parseArgs(argv) {
1280
1326
  if (!out.confluenceSpaces.includes(v)) out.confluenceSpaces.push(v);
1281
1327
  break;
1282
1328
  }
1329
+ case "--confluence-folder": {
1330
+ const v = argv[++i];
1331
+ if (v === void 0 || v.startsWith("--")) throw new RangeError(`${a}には値が要ります`);
1332
+ const id = parseFolderId(v);
1333
+ if (!out.confluenceFolders.includes(id)) out.confluenceFolders.push(id);
1334
+ break;
1335
+ }
1283
1336
  case "--jira-project": {
1284
1337
  const v = argv[++i];
1285
1338
  if (v === void 0 || v.startsWith("--")) throw new RangeError(`${a}には値が要ります`);
@@ -1341,6 +1394,7 @@ const USAGE = [
1341
1394
  row("--atlassian-site HOST", "Atlassianのサイト(example.atlassian.net)。--atlassian-emailとセット"),
1342
1395
  row("--atlassian-email ADDR", "Atlassianのメールアドレス。トークンはキーチェーンから取る"),
1343
1396
  row("--confluence-space KEY", "齟齬を見るスペースキー(繰り返し可)。省略すると自分が関わった全ページ"),
1397
+ row("--confluence-folder ID", "見張るフォルダ(繰り返し可)。IDでもフォルダのURLでも可。配下は他人が足した / 直したページも知らせる"),
1344
1398
  row("--jira-project KEY", "取得するプロジェクトキー(繰り返し可)。省略すると全部"),
1345
1399
  row("--org NAME", "組織のrepoも取得する(繰り返し可)。組織では自分がauthorのコミットと、自分が作ったか担当のissue/PRだけ数える"),
1346
1400
  row("--data-dir DIR", "データの置き場(既定: $XDG_CACHE_HOME か ~/.cache の github-reporadar/data)"),
@@ -1505,7 +1559,7 @@ async function main() {
1505
1559
  const site = args.atlassianSite;
1506
1560
  const email = args.atlassianEmail;
1507
1561
  const jira = site && email ? { site, email, projects: args.jiraProjects } : null;
1508
- const confluence = site && email ? { site, email, spaces: args.confluenceSpaces } : null;
1562
+ const confluence = site && email ? { site, email, spaces: args.confluenceSpaces, folders: args.confluenceFolders } : null;
1509
1563
  if (args.command === "sync") {
1510
1564
  await runSync({ dataDir, days: args.days, orgs: args.orgs, jira, confluence });
1511
1565
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-reporadar",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "自分のGitHub活動を一晩ぶん眺める計器盤。npx github-reporadarでローカルに開く",
5
5
  "license": "MIT",
6
6
  "bin": {