github-reporadar 0.2.0 → 0.5.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-cli/cli.js CHANGED
@@ -271,7 +271,65 @@ function prMetaQuery(refs) {
271
271
  ${fields}
272
272
  }`;
273
273
  }
274
+ function refStateQuery(refs) {
275
+ const fields = refs.map((r, j) => {
276
+ const [owner, name] = r.repo.split("/");
277
+ return `r${j}: repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { issueOrPullRequest(number: ${r.number}) { __typename ... on PullRequest { state } ... on Issue { state } } }`;
278
+ }).join("\n");
279
+ return `query {
280
+ ${fields}
281
+ }`;
282
+ }
283
+ function parseRefState(node) {
284
+ const state = node?.state;
285
+ if (state === "MERGED") return "MERGED";
286
+ if (state === "CLOSED") return "CLOSED";
287
+ if (state === "OPEN") return "OPEN";
288
+ return null;
289
+ }
290
+ function refsFromCommits(repos) {
291
+ const seen = /* @__PURE__ */ new Set();
292
+ const out = [];
293
+ for (const repo of repos) {
294
+ for (const c of repo.lastCommits ?? []) {
295
+ for (const m of (c.message ?? "").matchAll(/(^|[\s(\[])#(\d+)\b/g)) {
296
+ const number = Number(m[2]);
297
+ const key2 = `${repo.nameWithOwner}#${number}`;
298
+ if (seen.has(key2)) continue;
299
+ seen.add(key2);
300
+ out.push({ repo: repo.nameWithOwner, number });
301
+ }
302
+ }
303
+ }
304
+ return out;
305
+ }
274
306
  const PR_META_CHUNK = 40;
307
+ async function fetchRefStates(token, refs, limit) {
308
+ const out = {};
309
+ const chunks = [];
310
+ for (let i = 0; i < refs.length; i += PR_META_CHUNK) chunks.push(refs.slice(i, i + PR_META_CHUNK));
311
+ await Promise.all(
312
+ chunks.map(
313
+ (chunk) => limit(async () => {
314
+ try {
315
+ const res = await fetch(`${API}/graphql`, {
316
+ method: "POST",
317
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
318
+ body: JSON.stringify({ query: refStateQuery(chunk) })
319
+ });
320
+ if (!res.ok) return;
321
+ const json = await res.json();
322
+ chunk.forEach((r, j) => {
323
+ const state = parseRefState(json.data?.[`r${j}`]?.issueOrPullRequest ?? null);
324
+ if (state) out[`${r.repo}#${r.number}`] = state;
325
+ });
326
+ } catch {
327
+ }
328
+ })
329
+ )
330
+ );
331
+ return out;
332
+ }
275
333
  async function fetchPrMeta(token, refs, limit) {
276
334
  const out = /* @__PURE__ */ new Map();
277
335
  const chunks = [];
@@ -330,6 +388,32 @@ function issuesUrls(fullName, login) {
330
388
  const me = encodeURIComponent(login);
331
389
  return [`${base}&creator=${me}`, `${base}&assignee=${me}`];
332
390
  }
391
+ function lastCommentUrl(key2) {
392
+ const m = /^(.+)#(\d+)$/.exec(key2);
393
+ if (!m) return null;
394
+ return `/repos/${m[1]}/issues/${m[2]}/comments?per_page=1&sort=created&direction=desc`;
395
+ }
396
+ function parseLastComment(json) {
397
+ if (!Array.isArray(json) || json.length === 0) return null;
398
+ const c = json[json.length - 1];
399
+ const author = c?.user?.login;
400
+ if (!author) return null;
401
+ return { author, at: c.created_at ?? "" };
402
+ }
403
+ async function fetchLastComments(token, keys, max) {
404
+ const out = /* @__PURE__ */ new Map();
405
+ for (const key2 of keys.slice(0, max)) {
406
+ const url = lastCommentUrl(key2);
407
+ if (!url) continue;
408
+ try {
409
+ const { json } = await rest(token, url);
410
+ const last = parseLastComment(json);
411
+ if (last) out.set(key2, last);
412
+ } catch {
413
+ }
414
+ }
415
+ return out;
416
+ }
333
417
  async function listOpenIssues(token, repo, login) {
334
418
  const items = [];
335
419
  const seen = /* @__PURE__ */ new Set();
@@ -487,15 +571,314 @@ async function fetchActivity(token, from, opts) {
487
571
  });
488
572
  return { login, repos, activity, issues };
489
573
  }
574
+ function buildJql(projects = []) {
575
+ const scope = projects.length > 0 ? `project in (${projects.join(",")}) AND ` : "";
576
+ return `${scope}(assignee = currentUser() OR reporter = currentUser()) AND statusCategory != Done ORDER BY updated DESC`;
577
+ }
578
+ function mapIssue(raw, site, myAccountId) {
579
+ const f = raw.fields;
580
+ return {
581
+ key: raw.key,
582
+ title: f.summary,
583
+ status: f.status.name,
584
+ statusCategory: f.status.statusCategory.key,
585
+ priority: f.priority?.name ?? null,
586
+ type: f.issuetype?.name ?? null,
587
+ project: f.project.key,
588
+ projectName: f.project.name,
589
+ updatedAt: f.updated,
590
+ url: `https://${site}/browse/${raw.key}`,
591
+ isMine: f.assignee?.accountId === myAccountId,
592
+ isSubtask: f.issuetype?.subtask ?? false
593
+ };
594
+ }
595
+ function getJiraToken(email) {
596
+ try {
597
+ return execFileSync("security", ["find-generic-password", "-s", "github-reporadar-jira", "-a", email, "-w"], {
598
+ encoding: "utf8"
599
+ }).trim();
600
+ } catch {
601
+ return process.env.RR_JIRA_TOKEN?.trim() || null;
602
+ }
603
+ }
604
+ const FIELDS = ["summary", "updated", "status", "priority", "issuetype", "project", "assignee", "reporter"];
605
+ const MAX = 100;
606
+ async function fetchJiraIssues(cfg) {
607
+ if (!cfg?.site || !cfg.email) return [];
608
+ const token = getJiraToken(cfg.email);
609
+ if (!token) return [];
610
+ const auth = Buffer.from(`${cfg.email}:${token}`).toString("base64");
611
+ const headers = { authorization: `Basic ${auth}`, accept: "application/json", "content-type": "application/json" };
612
+ const me = await fetch(`https://${cfg.site}/rest/api/3/myself`, { headers });
613
+ if (!me.ok) throw new Error(`Jira /myself HTTP ${me.status}`);
614
+ const { accountId } = await me.json();
615
+ const res = await fetch(`https://${cfg.site}/rest/api/3/search/jql`, {
616
+ method: "POST",
617
+ headers,
618
+ body: JSON.stringify({ jql: buildJql(cfg.projects ?? []), fields: FIELDS, maxResults: MAX })
619
+ });
620
+ if (!res.ok) throw new Error(`Jira /search HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
621
+ const json = await res.json();
622
+ return (json.issues ?? []).map((i) => mapIssue(i, cfg.site, accountId));
623
+ }
624
+ const SERVICES = ["github-reporadar-atlassian", "github-reporadar-jira"];
625
+ const ENV_KEYS = ["RR_ATLASSIAN_TOKEN", "RR_JIRA_TOKEN"];
626
+ function getAtlassianToken(email) {
627
+ for (const service of SERVICES) {
628
+ try {
629
+ const out = execFileSync("security", ["find-generic-password", "-s", service, "-a", email, "-w"], {
630
+ encoding: "utf8",
631
+ stdio: ["ignore", "pipe", "ignore"]
632
+ }).trim();
633
+ if (out) return out;
634
+ } catch {
635
+ }
636
+ }
637
+ for (const key2 of ENV_KEYS) {
638
+ const v = process.env[key2]?.trim();
639
+ if (v) return v;
640
+ }
641
+ return null;
642
+ }
643
+ function basicAuthHeaders(email, token) {
644
+ const auth = Buffer.from(`${email}:${token}`).toString("base64");
645
+ return { authorization: `Basic ${auth}`, accept: "application/json", "content-type": "application/json" };
646
+ }
647
+ function buildCql(spaces = []) {
648
+ const scope = spaces.length > 0 ? `space in (${spaces.join(",")}) AND ` : "";
649
+ return `${scope}contributor = currentUser() AND type = page ORDER BY lastmodified DESC`;
650
+ }
651
+ const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", "#39": "'" };
652
+ const decodeEntities = (t) => t.replace(/&([a-z]+|#\d+);/gi, (m, name) => ENTITIES[name.toLowerCase()] ?? m);
653
+ function stripMarkup(source) {
654
+ if (!source) return "";
655
+ return decodeEntities(
656
+ source.replace(/<!\[CDATA\[/g, "").replace(/\]\]>/g, "").replace(/<[^>]*>/g, " ")
657
+ ).replace(/\s+/g, " ").trim();
658
+ }
659
+ const ATTR_URL = /\b(?:href|ri:value)="([^"]*)"/gi;
660
+ function linkTargets(source) {
661
+ if (!source) return "";
662
+ const out = [];
663
+ for (const m of source.matchAll(ATTR_URL)) out.push(decodeEntities(m[1]));
664
+ return out.join(" ");
665
+ }
666
+ const BARE_MIN_WORD = 8;
667
+ const looksLikeRepoName = (bare) => /[-_.\d]/.test(bare) || bare.length >= BARE_MIN_WORD;
668
+ const NAME_CHAR = "[A-Za-z0-9_-]";
669
+ const BEFORE_FULL = "(^|[^A-Za-z0-9_-])";
670
+ const BEFORE_BARE = "(^|[^A-Za-z0-9_/-])";
671
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
672
+ function extractRepoRefs(text, knownRepos) {
673
+ if (!text) return [];
674
+ const count = /* @__PURE__ */ new Map();
675
+ for (const full of knownRepos) {
676
+ const bare = full.slice(full.indexOf("/") + 1).toLowerCase();
677
+ count.set(bare, (count.get(bare) ?? 0) + 1);
678
+ }
679
+ const found = [];
680
+ for (const full of knownRepos) {
681
+ const bare = full.slice(full.indexOf("/") + 1);
682
+ const fullHit = new RegExp(`${BEFORE_FULL}${escapeRe(full)}(?!${NAME_CHAR})`, "i").test(text);
683
+ const bareHit = looksLikeRepoName(bare) && count.get(bare.toLowerCase()) === 1 && new RegExp(`${BEFORE_BARE}${escapeRe(bare)}(?!${NAME_CHAR})`, "i").test(text);
684
+ if (fullHit || bareHit) found.push(full);
685
+ }
686
+ return found;
687
+ }
688
+ function mapPage(raw, site, knownRepos) {
689
+ const body = raw.body?.storage?.value;
690
+ const text = `${raw.title} ${linkTargets(body)} ${stripMarkup(body)}`;
691
+ const webui = raw._links?.webui ?? "";
692
+ return {
693
+ id: raw.id,
694
+ title: raw.title,
695
+ space: raw.space?.key ?? "",
696
+ spaceName: raw.space?.name ?? "",
697
+ url: webui ? `https://${site}/wiki${webui}` : `https://${site}/wiki/pages/viewpage.action?pageId=${raw.id}`,
698
+ lastModified: raw.version?.when ?? "",
699
+ lastModifiedBy: raw.version?.by?.accountId ?? "",
700
+ repos: extractRepoRefs(text, knownRepos)
701
+ };
702
+ }
703
+ const DAY = 24 * 60 * 60 * 1e3;
704
+ function driftingPages(pages, repos, minDays = 1) {
705
+ const pushedAt = new Map(repos.map((r) => [r.nameWithOwner, r.pushedAt]));
706
+ const out = [];
707
+ for (const page of pages) {
708
+ const modified = Date.parse(page.lastModified);
709
+ if (!Number.isFinite(modified)) continue;
710
+ for (const repo of page.repos) {
711
+ const push = pushedAt.get(repo);
712
+ const pushed = push ? Date.parse(push) : NaN;
713
+ if (!Number.isFinite(pushed)) continue;
714
+ const daysBehind = Math.floor((pushed - modified) / DAY);
715
+ if (daysBehind < minDays) continue;
716
+ out.push({
717
+ pageId: page.id,
718
+ title: page.title,
719
+ space: page.space,
720
+ url: page.url,
721
+ lastModified: page.lastModified,
722
+ repo,
723
+ pushedAt: push,
724
+ daysBehind
725
+ });
726
+ }
727
+ }
728
+ return out.sort((a, b) => b.daysBehind - a.daysBehind || a.title.localeCompare(b.title));
729
+ }
730
+ const EXPAND = "space,version,body.storage";
731
+ const PAGE_SIZE = 50;
732
+ const MAX_TOTAL = 200;
733
+ async function fetchMyAccountId(cfg) {
734
+ const token = getAtlassianToken(cfg.email);
735
+ if (!token) return null;
736
+ try {
737
+ const res = await fetch(`https://${cfg.site}/wiki/rest/api/user/current`, { headers: basicAuthHeaders(cfg.email, token) });
738
+ if (!res.ok) return null;
739
+ const json = await res.json();
740
+ return json.accountId ?? null;
741
+ } catch {
742
+ return null;
743
+ }
744
+ }
745
+ async function fetchConfluencePages(cfg, knownRepos) {
746
+ if (!cfg?.site || !cfg.email) return [];
747
+ const token = getAtlassianToken(cfg.email);
748
+ if (!token) return [];
749
+ const headers = basicAuthHeaders(cfg.email, token);
750
+ const cql = buildCql(cfg.spaces ?? []);
751
+ const out = [];
752
+ for (let start = 0; start < MAX_TOTAL; start += PAGE_SIZE) {
753
+ const params = new URLSearchParams({ cql, expand: EXPAND, limit: String(PAGE_SIZE), start: String(start) });
754
+ const res = await fetch(`https://${cfg.site}/wiki/rest/api/content/search?${params}`, { headers });
755
+ if (!res.ok) throw new Error(`Confluence /content/search HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
756
+ const json = await res.json();
757
+ const batch = json.results ?? [];
758
+ for (const raw of batch) if (raw?.id) out.push(mapPage(raw, cfg.site, knownRepos));
759
+ if (batch.length < PAGE_SIZE) break;
760
+ }
761
+ return out;
762
+ }
763
+ function channelName(channel) {
764
+ if (!channel) return "";
765
+ if (channel.is_mpim) return "グループDM";
766
+ if (channel.is_im) return "DM";
767
+ return channel.name ?? "";
768
+ }
769
+ function getSlackToken() {
770
+ try {
771
+ const out = execFileSync("security", ["find-generic-password", "-s", "github-reporadar-slack", "-a", "slack", "-w"], {
772
+ encoding: "utf8",
773
+ stdio: ["ignore", "pipe", "ignore"]
774
+ }).trim();
775
+ if (out) return out;
776
+ } catch {
777
+ }
778
+ return process.env.RR_SLACK_TOKEN?.trim() || null;
779
+ }
780
+ async function fetchMyUserId(token) {
781
+ try {
782
+ const res = await fetch("https://slack.com/api/auth.test", {
783
+ method: "POST",
784
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/x-www-form-urlencoded" }
785
+ });
786
+ if (!res.ok) return null;
787
+ const json = await res.json();
788
+ return json.ok ? json.user_id ?? null : null;
789
+ } catch {
790
+ return null;
791
+ }
792
+ }
793
+ function mentionQuery(userId) {
794
+ return `<@${userId}>`;
795
+ }
796
+ function mapMention(match) {
797
+ const permalink = match.permalink ?? "";
798
+ if (!permalink) return null;
799
+ return {
800
+ channel: channelName(match.channel),
801
+ permalink,
802
+ at: new Date(Number(match.ts ?? 0) * 1e3).toISOString(),
803
+ from: match.username ?? ""
804
+ };
805
+ }
806
+ const COUNT = 20;
807
+ async function fetchMyMentions(max = COUNT) {
808
+ const token = getSlackToken();
809
+ if (!token) return [];
810
+ const userId = await fetchMyUserId(token);
811
+ if (!userId) return [];
812
+ const body = new URLSearchParams({ query: mentionQuery(userId), count: String(max), sort: "timestamp" });
813
+ try {
814
+ const res = await fetch("https://slack.com/api/search.messages", {
815
+ method: "POST",
816
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/x-www-form-urlencoded" },
817
+ body: body.toString()
818
+ });
819
+ if (!res.ok) return [];
820
+ const json = await res.json();
821
+ if (!json.ok) return [];
822
+ const out = [];
823
+ for (const match of json.messages?.matches ?? []) {
824
+ const m = mapMention(match);
825
+ if (m) out.push(m);
826
+ }
827
+ return out;
828
+ } catch {
829
+ return [];
830
+ }
831
+ }
832
+ function diffWikiPages(prev, next, now, myAccountId) {
833
+ if (prev === null) return [];
834
+ const before = new Map(prev.map((p) => [p.id, p]));
835
+ const out = [];
836
+ for (const page of next) {
837
+ const b = before.get(page.id);
838
+ if (!b || !page.lastModified || page.lastModified <= b.lastModified) continue;
839
+ if (myAccountId && page.lastModifiedBy === myAccountId) continue;
840
+ out.push({
841
+ id: `wiki:${page.id}:${page.lastModified}`,
842
+ at: now,
843
+ kind: "wiki_updated",
844
+ repo: page.space,
845
+ number: 0,
846
+ title: page.title,
847
+ url: page.url,
848
+ source: "wiki"
849
+ });
850
+ }
851
+ return out;
852
+ }
490
853
  const same = (a, b) => a != null && a.toLowerCase() === b.toLowerCase();
491
854
  const key = (i) => `${i.repo}#${i.number}`;
855
+ function commentIncreased(prev, next) {
856
+ if (!prev) return [];
857
+ const before = new Map(prev.map((i) => [key(i), i.comments]));
858
+ const out = [];
859
+ for (const i of next) {
860
+ const b = before.get(key(i));
861
+ if (b !== void 0 && i.comments > b) out.push(key(i));
862
+ }
863
+ return out;
864
+ }
492
865
  function diffEvents(prev, next, login, now, opts = {}) {
493
866
  if (!login || prev === null) return [];
494
867
  const before = new Map(prev.filter((i) => i.type === "pr").map((i) => [key(i), i]));
868
+ const beforeAll = new Map(prev.map((i) => [key(i), i]));
495
869
  const knownRepo = (repo) => !opts.prevRepos || opts.prevRepos.has(repo);
496
870
  const repoStillHere = (repo) => !opts.nextRepos || opts.nextRepos.has(repo);
497
871
  const out = [];
498
872
  const base = (p) => ({ at: now, repo: p.repo, number: p.number, title: p.title, url: p.url });
873
+ for (const p of next) {
874
+ const b = beforeAll.get(key(p));
875
+ if (!b || !knownRepo(p.repo) || p.comments <= b.comments) continue;
876
+ const last = opts.lastComments?.get(key(p));
877
+ if (!last || same(last.author, login)) continue;
878
+ if (same(p.author, login)) {
879
+ out.push({ id: `${key(p)}:comment:${p.comments}`, kind: "commented", ...base(p), actor: last.author });
880
+ }
881
+ }
499
882
  for (const p of next) {
500
883
  if (p.type !== "pr") continue;
501
884
  const b = before.get(key(p));
@@ -504,6 +887,21 @@ function diffEvents(prev, next, login, now, opts = {}) {
504
887
  const wasReviewer = (b?.reviewers ?? []).some((r) => same(r, login));
505
888
  const isReviewer = (p.reviewers ?? []).some((r) => same(r, login));
506
889
  if (!mine && isReviewer && !wasReviewer) out.push({ id: `${key(p)}:requested`, kind: "review_requested", ...base(p), actor: p.author ?? void 0 });
890
+ const iReviewed = same(p.lastReview?.author, login) || same(b?.lastReview?.author, login);
891
+ if (!mine && b && (isReviewer || iReviewed) && !(isReviewer && !wasReviewer)) {
892
+ const lastComment = opts.lastComments?.get(key(p));
893
+ const newComment = p.comments > b.comments && lastComment && !same(lastComment.author, login);
894
+ const newPush = p.lastCommitAt && b.lastCommitAt && p.lastCommitAt > b.lastCommitAt;
895
+ if (newComment || newPush) {
896
+ const stamp = newPush ? p.lastCommitAt : `c${p.comments}`;
897
+ out.push({
898
+ id: `${key(p)}:review-update:${stamp}`,
899
+ kind: "review_update",
900
+ ...base(p),
901
+ actor: (newComment ? lastComment?.author : p.author) ?? void 0
902
+ });
903
+ }
904
+ }
507
905
  if (!mine) continue;
508
906
  const prevCount = b?.reviewCount ?? 0;
509
907
  const count = p.reviewCount ?? 0;
@@ -527,8 +925,30 @@ function mergeEvents(existing, fresh, max = 200) {
527
925
  const added = fresh.filter((e) => !seen.has(e.id));
528
926
  return [...added, ...existing].sort((a, b) => a.at < b.at ? 1 : a.at > b.at ? -1 : 0).slice(0, max);
529
927
  }
928
+ function diffSlackMentions(prev, next, now) {
929
+ if (prev === null) return [];
930
+ const before = new Set(prev.map((m) => m.permalink));
931
+ const out = [];
932
+ for (const m of next) {
933
+ if (!m.permalink || before.has(m.permalink)) continue;
934
+ out.push({
935
+ id: `slack:${m.permalink}`,
936
+ at: now,
937
+ kind: "slack_mention",
938
+ repo: m.channel,
939
+ number: 0,
940
+ title: "",
941
+ url: m.permalink,
942
+ actor: m.from || void 0,
943
+ source: "slack"
944
+ });
945
+ }
946
+ return out;
947
+ }
530
948
  const REPO_CACHE_DIR = join(import.meta.dirname, "..", "public", "data");
531
949
  const DEFAULT_WINDOW_DAYS = 90;
950
+ const MAX_COMMENT_LOOKUPS = 15;
951
+ const REF_STATE_CONCURRENCY = 4;
532
952
  async function runSync(opts) {
533
953
  const windowDays = opts.days ?? DEFAULT_WINDOW_DAYS;
534
954
  const log = opts.log ?? ((line) => console.log(line));
@@ -555,9 +975,11 @@ async function runSync(opts) {
555
975
  mkdirSync(opts.dataDir, { recursive: true });
556
976
  const prevIssues = readJson("issues.json");
557
977
  const prevRepos = readJson("repos.json");
978
+ const lastComments = await fetchLastComments(token, commentIncreased(prevIssues, issues), MAX_COMMENT_LOOKUPS);
558
979
  const fresh = diffEvents(prevIssues, issues, login, to.toISOString(), {
559
980
  prevRepos: prevRepos ? new Set(prevRepos.map((r) => r.nameWithOwner)) : void 0,
560
- nextRepos: new Set(repos.map((r) => r.nameWithOwner))
981
+ nextRepos: new Set(repos.map((r) => r.nameWithOwner)),
982
+ lastComments
561
983
  });
562
984
  const events = mergeEvents(readJson("events.json") ?? [], fresh);
563
985
  writeFileSync(join(opts.dataDir, "events.json"), JSON.stringify(events, null, 2));
@@ -567,6 +989,12 @@ async function runSync(opts) {
567
989
  writeFileSync(join(opts.dataDir, "activity.json"), JSON.stringify(activity, null, 2));
568
990
  writeFileSync(join(opts.dataDir, "issues.json"), JSON.stringify(issues, null, 2));
569
991
  writeFileSync(join(opts.dataDir, "meta.json"), JSON.stringify(meta, null, 2));
992
+ const refs = refsFromCommits(repos);
993
+ const refStates = await fetchRefStates(token, refs, createLimiter(REF_STATE_CONCURRENCY));
994
+ writeFileSync(join(opts.dataDir, "refs.json"), JSON.stringify(refStates, null, 2));
995
+ await writeJira(opts, log);
996
+ await writeConfluence(opts, repos, to.toISOString(), readJson, log);
997
+ await writeSlack(opts, to.toISOString(), readJson, log);
570
998
  const totalCommits = activity.reduce((sum, a) => sum + a.commits, 0);
571
999
  log(`OK: ${repos.length} repos / ${totalCommits} commits / ${issues.length} open issues+PRs → ${opts.dataDir}`);
572
1000
  return { login, repos: repos.length, commits: totalCommits, issues: issues.length, events: fresh.length };
@@ -585,7 +1013,8 @@ async function runLightSync(opts, log, readJson) {
585
1013
  const to = /* @__PURE__ */ new Date();
586
1014
  const prevIssues = readJson("issues.json");
587
1015
  const repoSet = new Set(names);
588
- const fresh = diffEvents(prevIssues, issues, login, to.toISOString(), { prevRepos: repoSet, nextRepos: repoSet });
1016
+ const lastComments = await fetchLastComments(token, commentIncreased(prevIssues, issues), MAX_COMMENT_LOOKUPS);
1017
+ const fresh = diffEvents(prevIssues, issues, login, to.toISOString(), { prevRepos: repoSet, nextRepos: repoSet, lastComments });
589
1018
  const events = mergeEvents(readJson("events.json") ?? [], fresh);
590
1019
  const repos = prevRepos.map((r) => {
591
1020
  const c = counts.get(r.nameWithOwner);
@@ -596,9 +1025,66 @@ async function runLightSync(opts, log, readJson) {
596
1025
  writeFileSync(join(opts.dataDir, "events.json"), JSON.stringify(events, null, 2));
597
1026
  writeFileSync(join(opts.dataDir, "repos.json"), JSON.stringify(repos, null, 2));
598
1027
  if (meta) writeFileSync(join(opts.dataDir, "meta.json"), JSON.stringify({ ...meta, lastSyncAt: to.toISOString() }, null, 2));
1028
+ await writeJira(opts, log);
1029
+ await writeConfluence(opts, repos, to.toISOString(), readJson, log);
1030
+ await writeSlack(opts, to.toISOString(), readJson, log);
599
1031
  log(`更新: open ${issues.length}件${fresh.length > 0 ? ` / 受信箱に${fresh.length}件` : ""}`);
600
1032
  return { login, repos: repos.length, commits: 0, issues: issues.length, events: fresh.length };
601
1033
  }
1034
+ async function writeJira(opts, log) {
1035
+ if (!opts.jira) return;
1036
+ try {
1037
+ const issues = await fetchJiraIssues(opts.jira);
1038
+ writeFileSync(join(opts.dataDir, "jira.json"), JSON.stringify(issues, null, 2));
1039
+ if (issues.length > 0) log(`Jira: 未完了${issues.length}件`);
1040
+ } catch (err) {
1041
+ log(`Jiraの取得に失敗: ${err instanceof Error ? err.message : String(err)}`);
1042
+ }
1043
+ }
1044
+ async function writeSlack(opts, now, readJson, log) {
1045
+ try {
1046
+ const mentions = await fetchMyMentions();
1047
+ if (mentions.length === 0) return;
1048
+ const prev = readJson("slack-mentions.json");
1049
+ const fresh = diffSlackMentions(prev, mentions, now);
1050
+ writeFileSync(join(opts.dataDir, "slack-mentions.json"), JSON.stringify(mentions, null, 2));
1051
+ if (fresh.length > 0) {
1052
+ const events = mergeEvents(readJson("events.json") ?? [], fresh);
1053
+ writeFileSync(join(opts.dataDir, "events.json"), JSON.stringify(events, null, 2));
1054
+ log(`Slack: メンション${fresh.length}件を受信箱へ`);
1055
+ }
1056
+ } catch (err) {
1057
+ log(`Slackの取得に失敗: ${err instanceof Error ? err.message : String(err)}`);
1058
+ }
1059
+ }
1060
+ async function writeConfluence(opts, repos, now, readJson, log) {
1061
+ if (!opts.confluence) return;
1062
+ try {
1063
+ const pages = await fetchConfluencePages(opts.confluence, repos.map((r) => r.nameWithOwner));
1064
+ const drifts = driftingPages(pages, repos.map((r) => ({ nameWithOwner: r.nameWithOwner, pushedAt: r.pushedAt })));
1065
+ writeFileSync(join(opts.dataDir, "confluence.json"), JSON.stringify(drifts, null, 2));
1066
+ if (drifts.length > 0) log(`Confluence: 要確認${drifts.length}件`);
1067
+ const refs = pages.map((p) => ({
1068
+ id: p.id,
1069
+ title: p.title,
1070
+ space: p.space,
1071
+ url: p.url,
1072
+ lastModified: p.lastModified,
1073
+ lastModifiedBy: p.lastModifiedBy
1074
+ }));
1075
+ const prevPages = readJson("confluence-pages.json");
1076
+ const myAccountId = prevPages === null ? null : await fetchMyAccountId(opts.confluence);
1077
+ const fresh = diffWikiPages(prevPages, refs, now, myAccountId);
1078
+ writeFileSync(join(opts.dataDir, "confluence-pages.json"), JSON.stringify(refs, null, 2));
1079
+ if (fresh.length > 0) {
1080
+ const events = mergeEvents(readJson("events.json") ?? [], fresh);
1081
+ writeFileSync(join(opts.dataDir, "events.json"), JSON.stringify(events, null, 2));
1082
+ log(`Confluence: 更新${fresh.length}件を受信箱へ`);
1083
+ }
1084
+ } catch (err) {
1085
+ log(`Confluenceの取得に失敗: ${err instanceof Error ? err.message : String(err)}`);
1086
+ }
1087
+ }
602
1088
  const isMain = process.argv[1]?.endsWith("sync.ts") || process.argv[1]?.endsWith("sync.js");
603
1089
  if (isMain) {
604
1090
  const daysArg = process.argv.indexOf("--days");
@@ -622,7 +1108,11 @@ const DEFAULTS = {
622
1108
  sort: "active",
623
1109
  limit: 15,
624
1110
  orgs: [],
625
- watchSeconds: null
1111
+ watchSeconds: null,
1112
+ atlassianSite: null,
1113
+ atlassianEmail: null,
1114
+ jiraProjects: [],
1115
+ confluenceSpaces: []
626
1116
  };
627
1117
  function intIn(flag, raw, min, max) {
628
1118
  if (raw === void 0 || raw.startsWith("--")) throw new RangeError(`${flag} には値が要ります`);
@@ -633,7 +1123,7 @@ function intIn(flag, raw, min, max) {
633
1123
  return n;
634
1124
  }
635
1125
  function parseArgs(argv) {
636
- const out = { ...DEFAULTS, orgs: [] };
1126
+ const out = { ...DEFAULTS, orgs: [], jiraProjects: [], confluenceSpaces: [] };
637
1127
  let command = null;
638
1128
  for (let i = 0; i < argv.length; i++) {
639
1129
  const a = argv[i];
@@ -666,6 +1156,31 @@ function parseArgs(argv) {
666
1156
  out.dataDir = v;
667
1157
  break;
668
1158
  }
1159
+ // --jira-*はConfluenceを足す前からある名前。同じ資格情報なので受け続ける
1160
+ case "--atlassian-site":
1161
+ case "--atlassian-email":
1162
+ case "--jira-site":
1163
+ case "--jira-email": {
1164
+ const v = argv[++i];
1165
+ if (v === void 0 || v.startsWith("--")) throw new RangeError(`${a}には値が要ります`);
1166
+ if (a.endsWith("-site")) out.atlassianSite = v;
1167
+ else out.atlassianEmail = v;
1168
+ break;
1169
+ }
1170
+ case "--confluence-space": {
1171
+ const v = argv[++i];
1172
+ if (v === void 0 || v.startsWith("--")) throw new RangeError(`${a}には値が要ります`);
1173
+ if (!/^[A-Za-z0-9_~]+$/.test(v)) throw new RangeError(`--confluence-spaceはスペースキー(英数字)で指定してください(受け取った値: ${v})`);
1174
+ if (!out.confluenceSpaces.includes(v)) out.confluenceSpaces.push(v);
1175
+ break;
1176
+ }
1177
+ case "--jira-project": {
1178
+ const v = argv[++i];
1179
+ if (v === void 0 || v.startsWith("--")) throw new RangeError(`${a}には値が要ります`);
1180
+ if (!/^[A-Z][A-Z0-9_]*$/.test(v)) throw new RangeError(`--jira-projectはプロジェクトキー(英大文字と数字)で指定してください(受け取った値: ${v})`);
1181
+ if (!out.jiraProjects.includes(v)) out.jiraProjects.push(v);
1182
+ break;
1183
+ }
669
1184
  case "--watch": {
670
1185
  const v = argv[++i];
671
1186
  const m = v && !v.startsWith("--") ? /^(\d+)(s|m|h)$/.exec(v) : null;
@@ -717,6 +1232,10 @@ const USAGE = [
717
1232
  row("--no-open", "ブラウザを開かない"),
718
1233
  row("--no-sync", "同期せずに手元のデータで開く"),
719
1234
  row("--watch 1m", "開いたまま、この間隔でopen issue/PRを取り直す(30秒以上)。受信箱に自分に起きたことが溜まる"),
1235
+ row("--atlassian-site HOST", "Atlassianのサイト(example.atlassian.net)。--atlassian-emailとセット"),
1236
+ row("--atlassian-email ADDR", "Atlassianのメールアドレス。トークンはキーチェーンから取る"),
1237
+ row("--confluence-space KEY", "齟齬を見るスペースキー(繰り返し可)。省略すると自分が関わった全ページ"),
1238
+ row("--jira-project KEY", "取得するプロジェクトキー(繰り返し可)。省略すると全部"),
720
1239
  row("--org NAME", "組織のrepoも取得する(繰り返し可)。組織では自分がauthorのコミットと、自分が作ったか担当のissue/PRだけ数える"),
721
1240
  row("--data-dir DIR", "データの置き場(既定: $XDG_CACHE_HOME か ~/.cache の github-reporadar/data)"),
722
1241
  row("--sort KEY", "brief の並び: active | recent | stale"),
@@ -732,8 +1251,10 @@ function defaultDataDir(env, home) {
732
1251
  return join(base, "github-reporadar", "data");
733
1252
  }
734
1253
  const HOST = "127.0.0.1";
735
- const DATA_FILES = /* @__PURE__ */ new Set(["repos.json", "activity.json", "issues.json", "meta.json", "events.json"]);
1254
+ const DATA_FILES = /* @__PURE__ */ new Set(["repos.json", "activity.json", "issues.json", "meta.json", "events.json", "jira.json", "confluence.json", "confluence-pages.json", "slack-mentions.json", "refs.json"]);
736
1255
  const PORT_TRIES = 20;
1256
+ const COACH_LOG = "coach-log.md";
1257
+ const COACH_LOG_MAX = 1024 * 1024;
737
1258
  const TYPES = {
738
1259
  ".html": "text/html; charset=utf-8",
739
1260
  ".js": "text/javascript; charset=utf-8",
@@ -776,6 +1297,7 @@ function resolveRequest(rawPath, dist, dataDir) {
776
1297
  const file = insideRoot(dataDir, name);
777
1298
  return file ? { file, fallback: false } : null;
778
1299
  }
1300
+ if (pathname.startsWith("/api/")) return null;
779
1301
  const candidate = insideRoot(dist, pathname);
780
1302
  if (candidate && isFile(candidate)) return { file: candidate, fallback: false };
781
1303
  return { file: join(dist, "index.html"), fallback: true };
@@ -798,6 +1320,29 @@ function listen(server, port) {
798
1320
  }
799
1321
  async function startServer(opts) {
800
1322
  const server = createServer((req, res) => {
1323
+ if (req.method === "POST" && (req.url ?? "").split("?")[0] === "/api/coach-log") {
1324
+ let body = "";
1325
+ let over = false;
1326
+ req.on("data", (chunk) => {
1327
+ if (over) return;
1328
+ body += chunk.toString("utf8");
1329
+ if (body.length > COACH_LOG_MAX) {
1330
+ over = true;
1331
+ body = "";
1332
+ res.writeHead(413).end();
1333
+ }
1334
+ });
1335
+ req.on("end", () => {
1336
+ if (over) return;
1337
+ try {
1338
+ writeFileSync(join(opts.dataDir, COACH_LOG), body);
1339
+ res.writeHead(204).end();
1340
+ } catch {
1341
+ res.writeHead(500).end();
1342
+ }
1343
+ });
1344
+ return;
1345
+ }
801
1346
  if (req.method !== "GET" && req.method !== "HEAD") {
802
1347
  res.writeHead(405).end();
803
1348
  return;
@@ -851,17 +1396,26 @@ async function main() {
851
1396
  return;
852
1397
  }
853
1398
  const dataDir = args.dataDir ?? defaultDataDir(process.env, homedir());
854
- if (args.command === "sync" || args.command === "serve" && args.sync) {
855
- await runSync({ dataDir, days: args.days, orgs: args.orgs });
1399
+ const site = args.atlassianSite;
1400
+ const email = args.atlassianEmail;
1401
+ const jira = site && email ? { site, email, projects: args.jiraProjects } : null;
1402
+ const confluence = site && email ? { site, email, spaces: args.confluenceSpaces } : null;
1403
+ if (args.command === "sync") {
1404
+ await runSync({ dataDir, days: args.days, orgs: args.orgs, jira, confluence });
1405
+ return;
856
1406
  }
857
- if (args.command === "sync") return;
858
1407
  if (args.command === "brief") {
859
1408
  runBrief({ dataDir, days: args.days, sort: args.sort, limit: args.limit, includePrivate: args.includePrivate });
860
1409
  return;
861
1410
  }
862
- if (!existsSync(join(dataDir, "meta.json"))) {
863
- throw new Error(`データがありません: ${dataDir}
1411
+ const hasData = existsSync(join(dataDir, "meta.json"));
1412
+ if (!hasData) {
1413
+ if (!args.sync) {
1414
+ throw new Error(`データがありません: ${dataDir}
864
1415
  先に \`github-reporadar sync\`を実行するか、--no-syncを外してください`);
1416
+ }
1417
+ console.log("はじめての同期です。1分ほどかかります");
1418
+ await runSync({ dataDir, days: args.days, orgs: args.orgs, jira, confluence });
865
1419
  }
866
1420
  const dist = distDir();
867
1421
  if (!existsSync(join(dist, "index.html"))) {
@@ -873,6 +1427,12 @@ async function main() {
873
1427
  console.log(`GitHub RepoRadar: ${url} (データ: ${dataDir})`);
874
1428
  console.log("止めるにはCtrl+C");
875
1429
  if (args.open) openBrowser(url);
1430
+ if (hasData && args.sync) {
1431
+ console.log("裏で同期しています");
1432
+ runSync({ dataDir, days: args.days, orgs: args.orgs, jira, confluence, log: (line) => console.log(`[sync] ${line}`) }).catch(
1433
+ (err) => console.error(`同期に失敗: ${err instanceof Error ? err.message : String(err)}`)
1434
+ );
1435
+ }
876
1436
  let timer = null;
877
1437
  if (args.watchSeconds) {
878
1438
  const FULL_EVERY_MS = 30 * 60 * 1e3;
@@ -891,6 +1451,8 @@ async function main() {
891
1451
  dataDir,
892
1452
  days: args.days,
893
1453
  orgs: args.orgs,
1454
+ jira,
1455
+ confluence,
894
1456
  mode: full ? "full" : "light",
895
1457
  log: (line) => console.log(`[watch] ${line}`)
896
1458
  }).catch((err) => console.error(`[watch] 同期に失敗: ${err instanceof Error ? err.message : String(err)}`)).finally(() => {