github-reporadar 0.2.1 → 0.6.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,373 @@ async function fetchActivity(token, from, opts) {
487
571
  });
488
572
  return { login, repos, activity, issues };
489
573
  }
574
+ const SERVICES = ["github-reporadar-atlassian", "github-reporadar-jira"];
575
+ const ENV_KEYS = ["RR_ATLASSIAN_TOKEN", "RR_JIRA_TOKEN"];
576
+ function getAtlassianToken(email) {
577
+ for (const service of SERVICES) {
578
+ try {
579
+ const out = execFileSync("security", ["find-generic-password", "-s", service, "-a", email, "-w"], {
580
+ encoding: "utf8",
581
+ stdio: ["ignore", "pipe", "ignore"]
582
+ }).trim();
583
+ if (out) return out;
584
+ } catch {
585
+ }
586
+ }
587
+ for (const key2 of ENV_KEYS) {
588
+ const v = process.env[key2]?.trim();
589
+ if (v) return v;
590
+ }
591
+ return null;
592
+ }
593
+ function basicAuthHeaders(email, token) {
594
+ const auth = Buffer.from(`${email}:${token}`).toString("base64");
595
+ return { authorization: `Basic ${auth}`, accept: "application/json", "content-type": "application/json" };
596
+ }
597
+ function authHint(status, email) {
598
+ if (status !== 401 && status !== 403) return "";
599
+ return [
600
+ "",
601
+ ` ${status === 401 ? "認証されませんでした" : "拒否されました"}。メールとトークンの組が受け付けられていません`,
602
+ ` トークンを作り直して入れ直します(既にある登録を置き換えるので -U を付ける)`,
603
+ ` security add-generic-password -U -s github-reporadar-atlassian -a "${email}" -w`,
604
+ ` 値を聞かれたら貼り付けます。-w のうしろに直接書くと、長いので途中で切れることがあります`,
605
+ ` 作成先: https://id.atlassian.com/manage-profile/security/api-tokens`,
606
+ ` そこに出ているアカウントが ${email} であることも確かめます`
607
+ ].join("\n");
608
+ }
609
+ function buildJql(projects = []) {
610
+ const scope = projects.length > 0 ? `project in (${projects.join(",")}) AND ` : "";
611
+ return `${scope}(assignee = currentUser() OR reporter = currentUser()) AND statusCategory != Done ORDER BY updated DESC`;
612
+ }
613
+ function mapIssue(raw, site, myAccountId) {
614
+ const f = raw.fields;
615
+ return {
616
+ key: raw.key,
617
+ title: f.summary,
618
+ status: f.status.name,
619
+ statusCategory: f.status.statusCategory.key,
620
+ priority: f.priority?.name ?? null,
621
+ type: f.issuetype?.name ?? null,
622
+ project: f.project.key,
623
+ projectName: f.project.name,
624
+ updatedAt: f.updated,
625
+ url: `https://${site}/browse/${raw.key}`,
626
+ isMine: f.assignee?.accountId === myAccountId,
627
+ isSubtask: f.issuetype?.subtask ?? false
628
+ };
629
+ }
630
+ function getJiraToken(email) {
631
+ return getAtlassianToken(email);
632
+ }
633
+ const FIELDS = ["summary", "updated", "status", "priority", "issuetype", "project", "assignee", "reporter"];
634
+ const MAX = 100;
635
+ async function fetchJiraIssues(cfg) {
636
+ if (!cfg?.site || !cfg.email) return [];
637
+ const token = getJiraToken(cfg.email);
638
+ if (!token) return [];
639
+ const headers = basicAuthHeaders(cfg.email, token);
640
+ const me = await fetch(`https://${cfg.site}/rest/api/3/myself`, { headers });
641
+ if (!me.ok) throw new Error(`Jira /myself HTTP ${me.status}${authHint(me.status, cfg.email)}`);
642
+ const { accountId } = await me.json();
643
+ const res = await fetch(`https://${cfg.site}/rest/api/3/search/jql`, {
644
+ method: "POST",
645
+ headers,
646
+ body: JSON.stringify({ jql: buildJql(cfg.projects ?? []), fields: FIELDS, maxResults: MAX })
647
+ });
648
+ if (!res.ok) throw new Error(`Jira /search HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
649
+ const json = await res.json();
650
+ return (json.issues ?? []).map((i) => mapIssue(i, cfg.site, accountId));
651
+ }
652
+ function buildCql(spaces = []) {
653
+ const scope = spaces.length > 0 ? `space in (${spaces.join(",")}) AND ` : "";
654
+ return `${scope}contributor = currentUser() AND type = page ORDER BY lastmodified DESC`;
655
+ }
656
+ const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", "#39": "'" };
657
+ const decodeEntities = (t) => t.replace(/&([a-z]+|#\d+);/gi, (m, name) => ENTITIES[name.toLowerCase()] ?? m);
658
+ function stripMarkup(source) {
659
+ if (!source) return "";
660
+ return decodeEntities(
661
+ source.replace(/<!\[CDATA\[/g, "").replace(/\]\]>/g, "").replace(/<[^>]*>/g, " ")
662
+ ).replace(/\s+/g, " ").trim();
663
+ }
664
+ const ATTR_URL = /\b(?:href|ri:value)="([^"]*)"/gi;
665
+ function linkTargets(source) {
666
+ if (!source) return "";
667
+ const out = [];
668
+ for (const m of source.matchAll(ATTR_URL)) out.push(decodeEntities(m[1]));
669
+ return out.join(" ");
670
+ }
671
+ const BARE_MIN_WORD = 8;
672
+ const looksLikeRepoName = (bare) => /[-_.\d]/.test(bare) || bare.length >= BARE_MIN_WORD;
673
+ const NAME_CHAR = "[A-Za-z0-9_-]";
674
+ const BEFORE_FULL = "(^|[^A-Za-z0-9_-])";
675
+ const BEFORE_BARE = "(^|[^A-Za-z0-9_/-])";
676
+ const escapeRe$1 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
677
+ function extractRepoRefs(text, knownRepos) {
678
+ if (!text) return [];
679
+ const count = /* @__PURE__ */ new Map();
680
+ for (const full of knownRepos) {
681
+ const bare = full.slice(full.indexOf("/") + 1).toLowerCase();
682
+ count.set(bare, (count.get(bare) ?? 0) + 1);
683
+ }
684
+ const found = [];
685
+ for (const full of knownRepos) {
686
+ const bare = full.slice(full.indexOf("/") + 1);
687
+ const fullHit = new RegExp(`${BEFORE_FULL}${escapeRe$1(full)}(?!${NAME_CHAR})`, "i").test(text);
688
+ const bareHit = looksLikeRepoName(bare) && count.get(bare.toLowerCase()) === 1 && new RegExp(`${BEFORE_BARE}${escapeRe$1(bare)}(?!${NAME_CHAR})`, "i").test(text);
689
+ if (fullHit || bareHit) found.push(full);
690
+ }
691
+ return found;
692
+ }
693
+ function mapPage(raw, site, knownRepos) {
694
+ const body = raw.body?.storage?.value;
695
+ const text = `${raw.title} ${linkTargets(body)} ${stripMarkup(body)}`;
696
+ const webui = raw._links?.webui ?? "";
697
+ return {
698
+ id: raw.id,
699
+ title: raw.title,
700
+ space: raw.space?.key ?? "",
701
+ spaceName: raw.space?.name ?? "",
702
+ url: webui ? `https://${site}/wiki${webui}` : `https://${site}/wiki/pages/viewpage.action?pageId=${raw.id}`,
703
+ lastModified: raw.version?.when ?? "",
704
+ lastModifiedBy: raw.version?.by?.accountId ?? "",
705
+ repos: extractRepoRefs(text, knownRepos)
706
+ };
707
+ }
708
+ const DAY = 24 * 60 * 60 * 1e3;
709
+ function driftingPages(pages, repos, minDays = 1) {
710
+ const pushedAt = new Map(repos.map((r) => [r.nameWithOwner, r.pushedAt]));
711
+ const out = [];
712
+ for (const page of pages) {
713
+ const modified = Date.parse(page.lastModified);
714
+ if (!Number.isFinite(modified)) continue;
715
+ for (const repo of page.repos) {
716
+ const push = pushedAt.get(repo);
717
+ const pushed = push ? Date.parse(push) : NaN;
718
+ if (!Number.isFinite(pushed)) continue;
719
+ const daysBehind = Math.floor((pushed - modified) / DAY);
720
+ if (daysBehind < minDays) continue;
721
+ out.push({
722
+ pageId: page.id,
723
+ title: page.title,
724
+ space: page.space,
725
+ url: page.url,
726
+ lastModified: page.lastModified,
727
+ repo,
728
+ pushedAt: push,
729
+ daysBehind
730
+ });
731
+ }
732
+ }
733
+ return out.sort((a, b) => b.daysBehind - a.daysBehind || a.title.localeCompare(b.title));
734
+ }
735
+ const EXPAND = "space,version,body.storage";
736
+ const PAGE_SIZE = 50;
737
+ const MAX_TOTAL = 200;
738
+ async function fetchMyAccountId(cfg) {
739
+ const token = getAtlassianToken(cfg.email);
740
+ if (!token) return null;
741
+ try {
742
+ const res = await fetch(`https://${cfg.site}/wiki/rest/api/user/current`, { headers: basicAuthHeaders(cfg.email, token) });
743
+ if (!res.ok) return null;
744
+ const json = await res.json();
745
+ return json.accountId ?? null;
746
+ } catch {
747
+ return null;
748
+ }
749
+ }
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 ?? []);
756
+ 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 });
760
+ if (!res.ok)
761
+ throw new Error(`Confluence /content/search HTTP ${res.status}: ${(await res.text()).slice(0, 200)}${authHint(res.status, cfg.email)}`);
762
+ 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;
766
+ }
767
+ return out;
768
+ }
769
+ function buildSlackQuery(repo) {
770
+ return `"${repo}"`;
771
+ }
772
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
773
+ function extractRefs(text, repo) {
774
+ if (!text) return [];
775
+ const re = new RegExp(`github\\.com/${escapeRe(repo)}/(pull|issues)/(\\d+)`, "gi");
776
+ const seen = /* @__PURE__ */ new Set();
777
+ const out = [];
778
+ for (const m of text.matchAll(re)) {
779
+ const number = Number(m[2]);
780
+ if (seen.has(number)) continue;
781
+ seen.add(number);
782
+ out.push({ number, type: m[1].toLowerCase() === "pull" ? "pr" : "issue" });
783
+ }
784
+ return out;
785
+ }
786
+ function channelName(channel) {
787
+ if (!channel) return "";
788
+ if (channel.is_mpim) return "グループDM";
789
+ if (channel.is_im) return "DM";
790
+ return channel.name ?? "";
791
+ }
792
+ function mapMatch(match, repo) {
793
+ const permalink = match.permalink ?? "";
794
+ if (!permalink) return [];
795
+ const at = new Date(Number(match.ts ?? 0) * 1e3).toISOString();
796
+ const channel = channelName(match.channel);
797
+ return extractRefs(match.text, repo).map((ref) => ({
798
+ repo,
799
+ number: ref.number,
800
+ type: ref.type,
801
+ channel,
802
+ permalink,
803
+ at
804
+ }));
805
+ }
806
+ function getSlackToken() {
807
+ try {
808
+ const out = execFileSync("security", ["find-generic-password", "-s", "github-reporadar-slack", "-a", "slack", "-w"], {
809
+ encoding: "utf8",
810
+ stdio: ["ignore", "pipe", "ignore"]
811
+ }).trim();
812
+ if (out) return out;
813
+ } catch {
814
+ }
815
+ return process.env.RR_SLACK_TOKEN?.trim() || null;
816
+ }
817
+ async function fetchMyUserId(token) {
818
+ try {
819
+ const res = await fetch("https://slack.com/api/auth.test", {
820
+ method: "POST",
821
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/x-www-form-urlencoded" }
822
+ });
823
+ if (!res.ok) return null;
824
+ const json = await res.json();
825
+ return json.ok ? json.user_id ?? null : null;
826
+ } catch {
827
+ return null;
828
+ }
829
+ }
830
+ function mentionQuery(userId) {
831
+ return `<@${userId}>`;
832
+ }
833
+ function mapMention(match) {
834
+ const permalink = match.permalink ?? "";
835
+ if (!permalink) return null;
836
+ return {
837
+ channel: channelName(match.channel),
838
+ permalink,
839
+ at: new Date(Number(match.ts ?? 0) * 1e3).toISOString(),
840
+ from: match.username ?? ""
841
+ };
842
+ }
843
+ const COUNT = 20;
844
+ async function fetchSlackLinks(repos, maxRepos) {
845
+ if (repos.length === 0) return [];
846
+ const token = getSlackToken();
847
+ if (!token) return [];
848
+ const out = [];
849
+ for (const repo of repos.slice(0, maxRepos)) {
850
+ const body = new URLSearchParams({ query: buildSlackQuery(repo), count: String(COUNT), sort: "timestamp" });
851
+ try {
852
+ const res = await fetch("https://slack.com/api/search.messages", {
853
+ method: "POST",
854
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/x-www-form-urlencoded" },
855
+ body: body.toString()
856
+ });
857
+ if (!res.ok) continue;
858
+ const json = await res.json();
859
+ if (!json.ok) continue;
860
+ for (const match of json.messages?.matches ?? []) out.push(...mapMatch(match, repo));
861
+ } catch {
862
+ }
863
+ }
864
+ return out;
865
+ }
866
+ async function fetchMyMentions(max = COUNT) {
867
+ const token = getSlackToken();
868
+ if (!token) return [];
869
+ const userId = await fetchMyUserId(token);
870
+ if (!userId) return [];
871
+ const body = new URLSearchParams({ query: mentionQuery(userId), count: String(max), sort: "timestamp" });
872
+ try {
873
+ const res = await fetch("https://slack.com/api/search.messages", {
874
+ method: "POST",
875
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/x-www-form-urlencoded" },
876
+ body: body.toString()
877
+ });
878
+ if (!res.ok) return [];
879
+ const json = await res.json();
880
+ if (!json.ok) return [];
881
+ const out = [];
882
+ for (const match of json.messages?.matches ?? []) {
883
+ const m = mapMention(match);
884
+ if (m) out.push(m);
885
+ }
886
+ return out;
887
+ } catch {
888
+ return [];
889
+ }
890
+ }
891
+ function diffWikiPages(prev, next, now, myAccountId) {
892
+ if (prev === null) return [];
893
+ const before = new Map(prev.map((p) => [p.id, p]));
894
+ const out = [];
895
+ for (const page of next) {
896
+ 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
+ });
909
+ }
910
+ return out;
911
+ }
490
912
  const same = (a, b) => a != null && a.toLowerCase() === b.toLowerCase();
491
913
  const key = (i) => `${i.repo}#${i.number}`;
914
+ function commentIncreased(prev, next) {
915
+ if (!prev) return [];
916
+ const before = new Map(prev.map((i) => [key(i), i.comments]));
917
+ const out = [];
918
+ for (const i of next) {
919
+ const b = before.get(key(i));
920
+ if (b !== void 0 && i.comments > b) out.push(key(i));
921
+ }
922
+ return out;
923
+ }
492
924
  function diffEvents(prev, next, login, now, opts = {}) {
493
925
  if (!login || prev === null) return [];
494
926
  const before = new Map(prev.filter((i) => i.type === "pr").map((i) => [key(i), i]));
927
+ const beforeAll = new Map(prev.map((i) => [key(i), i]));
495
928
  const knownRepo = (repo) => !opts.prevRepos || opts.prevRepos.has(repo);
496
929
  const repoStillHere = (repo) => !opts.nextRepos || opts.nextRepos.has(repo);
497
930
  const out = [];
498
931
  const base = (p) => ({ at: now, repo: p.repo, number: p.number, title: p.title, url: p.url });
932
+ for (const p of next) {
933
+ const b = beforeAll.get(key(p));
934
+ if (!b || !knownRepo(p.repo) || p.comments <= b.comments) continue;
935
+ const last = opts.lastComments?.get(key(p));
936
+ if (!last || same(last.author, login)) continue;
937
+ if (same(p.author, login)) {
938
+ out.push({ id: `${key(p)}:comment:${p.comments}`, kind: "commented", ...base(p), actor: last.author });
939
+ }
940
+ }
499
941
  for (const p of next) {
500
942
  if (p.type !== "pr") continue;
501
943
  const b = before.get(key(p));
@@ -504,6 +946,21 @@ function diffEvents(prev, next, login, now, opts = {}) {
504
946
  const wasReviewer = (b?.reviewers ?? []).some((r) => same(r, login));
505
947
  const isReviewer = (p.reviewers ?? []).some((r) => same(r, login));
506
948
  if (!mine && isReviewer && !wasReviewer) out.push({ id: `${key(p)}:requested`, kind: "review_requested", ...base(p), actor: p.author ?? void 0 });
949
+ const iReviewed = same(p.lastReview?.author, login) || same(b?.lastReview?.author, login);
950
+ if (!mine && b && (isReviewer || iReviewed) && !(isReviewer && !wasReviewer)) {
951
+ const lastComment = opts.lastComments?.get(key(p));
952
+ const newComment = p.comments > b.comments && lastComment && !same(lastComment.author, login);
953
+ const newPush = p.lastCommitAt && b.lastCommitAt && p.lastCommitAt > b.lastCommitAt;
954
+ if (newComment || newPush) {
955
+ const stamp = newPush ? p.lastCommitAt : `c${p.comments}`;
956
+ out.push({
957
+ id: `${key(p)}:review-update:${stamp}`,
958
+ kind: "review_update",
959
+ ...base(p),
960
+ actor: (newComment ? lastComment?.author : p.author) ?? void 0
961
+ });
962
+ }
963
+ }
507
964
  if (!mine) continue;
508
965
  const prevCount = b?.reviewCount ?? 0;
509
966
  const count = p.reviewCount ?? 0;
@@ -527,8 +984,48 @@ function mergeEvents(existing, fresh, max = 200) {
527
984
  const added = fresh.filter((e) => !seen.has(e.id));
528
985
  return [...added, ...existing].sort((a, b) => a.at < b.at ? 1 : a.at > b.at ? -1 : 0).slice(0, max);
529
986
  }
987
+ function diffSlackMentions(prev, next, now) {
988
+ if (prev === null) return [];
989
+ const before = new Set(prev.map((m) => m.permalink));
990
+ const out = [];
991
+ for (const m of next) {
992
+ if (!m.permalink || before.has(m.permalink)) continue;
993
+ out.push({
994
+ id: `slack:${m.permalink}`,
995
+ at: now,
996
+ kind: "slack_mention",
997
+ repo: m.channel,
998
+ number: 0,
999
+ title: "",
1000
+ url: m.permalink,
1001
+ actor: m.from || void 0,
1002
+ source: "slack"
1003
+ });
1004
+ }
1005
+ return out;
1006
+ }
1007
+ function countSkipped(prev, next, events) {
1008
+ if (!prev) return 0;
1009
+ const before = new Map(prev.map((i) => [key(i), i]));
1010
+ const noticed = new Set(events.map((e) => `${e.repo}#${e.number}`));
1011
+ let n = 0;
1012
+ for (const i of next) {
1013
+ const b = before.get(key(i));
1014
+ if (!b || i.updatedAt <= b.updatedAt) continue;
1015
+ if (noticed.has(key(i))) continue;
1016
+ n++;
1017
+ }
1018
+ return n;
1019
+ }
1020
+ function mergeSkipped(existing, at, count, max = 200) {
1021
+ if (count <= 0) return [...existing].slice(0, max);
1022
+ return [{ at, count }, ...existing].slice(0, max);
1023
+ }
530
1024
  const REPO_CACHE_DIR = join(import.meta.dirname, "..", "public", "data");
531
1025
  const DEFAULT_WINDOW_DAYS = 90;
1026
+ const MAX_COMMENT_LOOKUPS = 15;
1027
+ const REF_STATE_CONCURRENCY = 4;
1028
+ const MAX_SLACK_REPOS = 12;
532
1029
  async function runSync(opts) {
533
1030
  const windowDays = opts.days ?? DEFAULT_WINDOW_DAYS;
534
1031
  const log = opts.log ?? ((line) => console.log(line));
@@ -555,18 +1052,29 @@ async function runSync(opts) {
555
1052
  mkdirSync(opts.dataDir, { recursive: true });
556
1053
  const prevIssues = readJson("issues.json");
557
1054
  const prevRepos = readJson("repos.json");
1055
+ const lastComments = await fetchLastComments(token, commentIncreased(prevIssues, issues), MAX_COMMENT_LOOKUPS);
558
1056
  const fresh = diffEvents(prevIssues, issues, login, to.toISOString(), {
559
1057
  prevRepos: prevRepos ? new Set(prevRepos.map((r) => r.nameWithOwner)) : void 0,
560
- nextRepos: new Set(repos.map((r) => r.nameWithOwner))
1058
+ nextRepos: new Set(repos.map((r) => r.nameWithOwner)),
1059
+ lastComments
561
1060
  });
562
1061
  const events = mergeEvents(readJson("events.json") ?? [], fresh);
563
1062
  writeFileSync(join(opts.dataDir, "events.json"), JSON.stringify(events, null, 2));
1063
+ writeSkipped(opts, readJson, to.toISOString(), countSkipped(prevIssues, issues, fresh));
564
1064
  if (fresh.length > 0) log(`受信箱に${fresh.length}件`);
565
1065
  const meta = { lastSyncAt: to.toISOString(), login, windowDays, includesPrivate: true, ...orgs.length > 0 ? { orgs } : {} };
566
1066
  writeFileSync(join(opts.dataDir, "repos.json"), JSON.stringify(repos, null, 2));
567
1067
  writeFileSync(join(opts.dataDir, "activity.json"), JSON.stringify(activity, null, 2));
568
1068
  writeFileSync(join(opts.dataDir, "issues.json"), JSON.stringify(issues, null, 2));
569
1069
  writeFileSync(join(opts.dataDir, "meta.json"), JSON.stringify(meta, null, 2));
1070
+ const refs = refsFromCommits(repos);
1071
+ const refStates = await fetchRefStates(token, refs, createLimiter(REF_STATE_CONCURRENCY));
1072
+ writeFileSync(join(opts.dataDir, "refs.json"), JSON.stringify(refStates, null, 2));
1073
+ warnMissingAtlassianToken(opts, log);
1074
+ await writeJira(opts, log);
1075
+ await writeConfluence(opts, repos, to.toISOString(), readJson, log);
1076
+ await writeSlack(opts, to.toISOString(), readJson, log);
1077
+ await writeSlackLinks(opts, issues, login, log);
570
1078
  const totalCommits = activity.reduce((sum, a) => sum + a.commits, 0);
571
1079
  log(`OK: ${repos.length} repos / ${totalCommits} commits / ${issues.length} open issues+PRs → ${opts.dataDir}`);
572
1080
  return { login, repos: repos.length, commits: totalCommits, issues: issues.length, events: fresh.length };
@@ -585,8 +1093,10 @@ async function runLightSync(opts, log, readJson) {
585
1093
  const to = /* @__PURE__ */ new Date();
586
1094
  const prevIssues = readJson("issues.json");
587
1095
  const repoSet = new Set(names);
588
- const fresh = diffEvents(prevIssues, issues, login, to.toISOString(), { prevRepos: repoSet, nextRepos: repoSet });
1096
+ const lastComments = await fetchLastComments(token, commentIncreased(prevIssues, issues), MAX_COMMENT_LOOKUPS);
1097
+ const fresh = diffEvents(prevIssues, issues, login, to.toISOString(), { prevRepos: repoSet, nextRepos: repoSet, lastComments });
589
1098
  const events = mergeEvents(readJson("events.json") ?? [], fresh);
1099
+ writeSkipped(opts, readJson, to.toISOString(), countSkipped(prevIssues, issues, fresh));
590
1100
  const repos = prevRepos.map((r) => {
591
1101
  const c = counts.get(r.nameWithOwner);
592
1102
  return c ? { ...r, openPrCount: c.pr, openIssueCount: c.issue } : r;
@@ -596,9 +1106,91 @@ async function runLightSync(opts, log, readJson) {
596
1106
  writeFileSync(join(opts.dataDir, "events.json"), JSON.stringify(events, null, 2));
597
1107
  writeFileSync(join(opts.dataDir, "repos.json"), JSON.stringify(repos, null, 2));
598
1108
  if (meta) writeFileSync(join(opts.dataDir, "meta.json"), JSON.stringify({ ...meta, lastSyncAt: to.toISOString() }, null, 2));
1109
+ await writeJira(opts, log);
1110
+ await writeConfluence(opts, repos, to.toISOString(), readJson, log);
1111
+ await writeSlack(opts, to.toISOString(), readJson, log);
599
1112
  log(`更新: open ${issues.length}件${fresh.length > 0 ? ` / 受信箱に${fresh.length}件` : ""}`);
600
1113
  return { login, repos: repos.length, commits: 0, issues: issues.length, events: fresh.length };
601
1114
  }
1115
+ async function writeJira(opts, log) {
1116
+ if (!opts.jira) return;
1117
+ try {
1118
+ const issues = await fetchJiraIssues(opts.jira);
1119
+ writeFileSync(join(opts.dataDir, "jira.json"), JSON.stringify(issues, null, 2));
1120
+ if (issues.length > 0) log(`Jira: 未完了${issues.length}件`);
1121
+ } catch (err) {
1122
+ log(`Jiraの取得に失敗: ${err instanceof Error ? err.message : String(err)}`);
1123
+ }
1124
+ }
1125
+ function writeSkipped(opts, readJson, at, count) {
1126
+ const entries = mergeSkipped(readJson("skipped.json") ?? [], at, count);
1127
+ writeFileSync(join(opts.dataDir, "skipped.json"), JSON.stringify(entries, null, 2));
1128
+ }
1129
+ async function writeSlack(opts, now, readJson, log) {
1130
+ try {
1131
+ const mentions = await fetchMyMentions();
1132
+ if (mentions.length === 0) return;
1133
+ const prev = readJson("slack-mentions.json");
1134
+ const fresh = diffSlackMentions(prev, mentions, now);
1135
+ writeFileSync(join(opts.dataDir, "slack-mentions.json"), JSON.stringify(mentions, null, 2));
1136
+ if (fresh.length > 0) {
1137
+ const events = mergeEvents(readJson("events.json") ?? [], fresh);
1138
+ writeFileSync(join(opts.dataDir, "events.json"), JSON.stringify(events, null, 2));
1139
+ log(`Slack: メンション${fresh.length}件を受信箱へ`);
1140
+ }
1141
+ } catch (err) {
1142
+ log(`Slackの取得に失敗: ${err instanceof Error ? err.message : String(err)}`);
1143
+ }
1144
+ }
1145
+ async function writeSlackLinks(opts, issues, login, log) {
1146
+ try {
1147
+ const me = login.toLowerCase();
1148
+ const mine = issues.filter((i) => (i.author ?? "").toLowerCase() === me);
1149
+ const repos = [...new Set(mine.map((i) => i.repo))];
1150
+ const links = await fetchSlackLinks(repos, MAX_SLACK_REPOS);
1151
+ if (links.length === 0) return;
1152
+ writeFileSync(join(opts.dataDir, "slack-links.json"), JSON.stringify(links, null, 2));
1153
+ log(`Slack: 自分のPR / issueに触れた発言${links.length}件`);
1154
+ } catch (err) {
1155
+ log(`Slackのリンク照合に失敗: ${err instanceof Error ? err.message : String(err)}`);
1156
+ }
1157
+ }
1158
+ function warnMissingAtlassianToken(opts, log) {
1159
+ const cfg = opts.confluence ?? opts.jira;
1160
+ if (!cfg?.site || !cfg.email) return;
1161
+ if (getAtlassianToken(cfg.email)) return;
1162
+ log(`Atlassianのトークンがキーチェーンにありません。JiraもConfluenceも取りません`);
1163
+ log(` security add-generic-password -s github-reporadar-atlassian -a "${cfg.email}" -w "<APIトークン>"`);
1164
+ log(` トークンは https://id.atlassian.com/manage-profile/security/api-tokens で作れます`);
1165
+ }
1166
+ async function writeConfluence(opts, repos, now, readJson, log) {
1167
+ if (!opts.confluence) return;
1168
+ try {
1169
+ 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 })));
1171
+ writeFileSync(join(opts.dataDir, "confluence.json"), JSON.stringify(drifts, null, 2));
1172
+ if (drifts.length > 0) log(`Confluence: 要確認${drifts.length}件`);
1173
+ const refs = pages.map((p) => ({
1174
+ id: p.id,
1175
+ title: p.title,
1176
+ space: p.space,
1177
+ url: p.url,
1178
+ lastModified: p.lastModified,
1179
+ lastModifiedBy: p.lastModifiedBy
1180
+ }));
1181
+ const prevPages = readJson("confluence-pages.json");
1182
+ const myAccountId = prevPages === null ? null : await fetchMyAccountId(opts.confluence);
1183
+ const fresh = diffWikiPages(prevPages, refs, now, myAccountId);
1184
+ writeFileSync(join(opts.dataDir, "confluence-pages.json"), JSON.stringify(refs, null, 2));
1185
+ if (fresh.length > 0) {
1186
+ const events = mergeEvents(readJson("events.json") ?? [], fresh);
1187
+ writeFileSync(join(opts.dataDir, "events.json"), JSON.stringify(events, null, 2));
1188
+ log(`Confluence: 更新${fresh.length}件を受信箱へ`);
1189
+ }
1190
+ } catch (err) {
1191
+ log(`Confluenceの取得に失敗: ${err instanceof Error ? err.message : String(err)}`);
1192
+ }
1193
+ }
602
1194
  const isMain = process.argv[1]?.endsWith("sync.ts") || process.argv[1]?.endsWith("sync.js");
603
1195
  if (isMain) {
604
1196
  const daysArg = process.argv.indexOf("--days");
@@ -622,7 +1214,11 @@ const DEFAULTS = {
622
1214
  sort: "active",
623
1215
  limit: 15,
624
1216
  orgs: [],
625
- watchSeconds: null
1217
+ watchSeconds: null,
1218
+ atlassianSite: null,
1219
+ atlassianEmail: null,
1220
+ jiraProjects: [],
1221
+ confluenceSpaces: []
626
1222
  };
627
1223
  function intIn(flag, raw, min, max) {
628
1224
  if (raw === void 0 || raw.startsWith("--")) throw new RangeError(`${flag} には値が要ります`);
@@ -633,7 +1229,7 @@ function intIn(flag, raw, min, max) {
633
1229
  return n;
634
1230
  }
635
1231
  function parseArgs(argv) {
636
- const out = { ...DEFAULTS, orgs: [] };
1232
+ const out = { ...DEFAULTS, orgs: [], jiraProjects: [], confluenceSpaces: [] };
637
1233
  let command = null;
638
1234
  for (let i = 0; i < argv.length; i++) {
639
1235
  const a = argv[i];
@@ -666,6 +1262,31 @@ function parseArgs(argv) {
666
1262
  out.dataDir = v;
667
1263
  break;
668
1264
  }
1265
+ // --jira-*はConfluenceを足す前からある名前。同じ資格情報なので受け続ける
1266
+ case "--atlassian-site":
1267
+ case "--atlassian-email":
1268
+ case "--jira-site":
1269
+ case "--jira-email": {
1270
+ const v = argv[++i];
1271
+ if (v === void 0 || v.startsWith("--")) throw new RangeError(`${a}には値が要ります`);
1272
+ if (a.endsWith("-site")) out.atlassianSite = v;
1273
+ else out.atlassianEmail = v;
1274
+ break;
1275
+ }
1276
+ case "--confluence-space": {
1277
+ const v = argv[++i];
1278
+ if (v === void 0 || v.startsWith("--")) throw new RangeError(`${a}には値が要ります`);
1279
+ if (!/^[A-Za-z0-9_~]+$/.test(v)) throw new RangeError(`--confluence-spaceはスペースキー(英数字)で指定してください(受け取った値: ${v})`);
1280
+ if (!out.confluenceSpaces.includes(v)) out.confluenceSpaces.push(v);
1281
+ break;
1282
+ }
1283
+ case "--jira-project": {
1284
+ const v = argv[++i];
1285
+ if (v === void 0 || v.startsWith("--")) throw new RangeError(`${a}には値が要ります`);
1286
+ if (!/^[A-Z][A-Z0-9_]*$/.test(v)) throw new RangeError(`--jira-projectはプロジェクトキー(英大文字と数字)で指定してください(受け取った値: ${v})`);
1287
+ if (!out.jiraProjects.includes(v)) out.jiraProjects.push(v);
1288
+ break;
1289
+ }
669
1290
  case "--watch": {
670
1291
  const v = argv[++i];
671
1292
  const m = v && !v.startsWith("--") ? /^(\d+)(s|m|h)$/.exec(v) : null;
@@ -717,6 +1338,10 @@ const USAGE = [
717
1338
  row("--no-open", "ブラウザを開かない"),
718
1339
  row("--no-sync", "同期せずに手元のデータで開く"),
719
1340
  row("--watch 1m", "開いたまま、この間隔でopen issue/PRを取り直す(30秒以上)。受信箱に自分に起きたことが溜まる"),
1341
+ row("--atlassian-site HOST", "Atlassianのサイト(example.atlassian.net)。--atlassian-emailとセット"),
1342
+ row("--atlassian-email ADDR", "Atlassianのメールアドレス。トークンはキーチェーンから取る"),
1343
+ row("--confluence-space KEY", "齟齬を見るスペースキー(繰り返し可)。省略すると自分が関わった全ページ"),
1344
+ row("--jira-project KEY", "取得するプロジェクトキー(繰り返し可)。省略すると全部"),
720
1345
  row("--org NAME", "組織のrepoも取得する(繰り返し可)。組織では自分がauthorのコミットと、自分が作ったか担当のissue/PRだけ数える"),
721
1346
  row("--data-dir DIR", "データの置き場(既定: $XDG_CACHE_HOME か ~/.cache の github-reporadar/data)"),
722
1347
  row("--sort KEY", "brief の並び: active | recent | stale"),
@@ -732,8 +1357,10 @@ function defaultDataDir(env, home) {
732
1357
  return join(base, "github-reporadar", "data");
733
1358
  }
734
1359
  const HOST = "127.0.0.1";
735
- const DATA_FILES = /* @__PURE__ */ new Set(["repos.json", "activity.json", "issues.json", "meta.json", "events.json"]);
1360
+ 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", "slack-links.json", "skipped.json"]);
736
1361
  const PORT_TRIES = 20;
1362
+ const COACH_LOG = "coach-log.md";
1363
+ const COACH_LOG_MAX = 1024 * 1024;
737
1364
  const TYPES = {
738
1365
  ".html": "text/html; charset=utf-8",
739
1366
  ".js": "text/javascript; charset=utf-8",
@@ -776,6 +1403,7 @@ function resolveRequest(rawPath, dist, dataDir) {
776
1403
  const file = insideRoot(dataDir, name);
777
1404
  return file ? { file, fallback: false } : null;
778
1405
  }
1406
+ if (pathname.startsWith("/api/")) return null;
779
1407
  const candidate = insideRoot(dist, pathname);
780
1408
  if (candidate && isFile(candidate)) return { file: candidate, fallback: false };
781
1409
  return { file: join(dist, "index.html"), fallback: true };
@@ -798,6 +1426,29 @@ function listen(server, port) {
798
1426
  }
799
1427
  async function startServer(opts) {
800
1428
  const server = createServer((req, res) => {
1429
+ if (req.method === "POST" && (req.url ?? "").split("?")[0] === "/api/coach-log") {
1430
+ let body = "";
1431
+ let over = false;
1432
+ req.on("data", (chunk) => {
1433
+ if (over) return;
1434
+ body += chunk.toString("utf8");
1435
+ if (body.length > COACH_LOG_MAX) {
1436
+ over = true;
1437
+ body = "";
1438
+ res.writeHead(413).end();
1439
+ }
1440
+ });
1441
+ req.on("end", () => {
1442
+ if (over) return;
1443
+ try {
1444
+ writeFileSync(join(opts.dataDir, COACH_LOG), body);
1445
+ res.writeHead(204).end();
1446
+ } catch {
1447
+ res.writeHead(500).end();
1448
+ }
1449
+ });
1450
+ return;
1451
+ }
801
1452
  if (req.method !== "GET" && req.method !== "HEAD") {
802
1453
  res.writeHead(405).end();
803
1454
  return;
@@ -851,8 +1502,12 @@ async function main() {
851
1502
  return;
852
1503
  }
853
1504
  const dataDir = args.dataDir ?? defaultDataDir(process.env, homedir());
1505
+ const site = args.atlassianSite;
1506
+ const email = args.atlassianEmail;
1507
+ const jira = site && email ? { site, email, projects: args.jiraProjects } : null;
1508
+ const confluence = site && email ? { site, email, spaces: args.confluenceSpaces } : null;
854
1509
  if (args.command === "sync") {
855
- await runSync({ dataDir, days: args.days, orgs: args.orgs });
1510
+ await runSync({ dataDir, days: args.days, orgs: args.orgs, jira, confluence });
856
1511
  return;
857
1512
  }
858
1513
  if (args.command === "brief") {
@@ -866,7 +1521,7 @@ async function main() {
866
1521
  先に \`github-reporadar sync\`を実行するか、--no-syncを外してください`);
867
1522
  }
868
1523
  console.log("はじめての同期です。1分ほどかかります");
869
- await runSync({ dataDir, days: args.days, orgs: args.orgs });
1524
+ await runSync({ dataDir, days: args.days, orgs: args.orgs, jira, confluence });
870
1525
  }
871
1526
  const dist = distDir();
872
1527
  if (!existsSync(join(dist, "index.html"))) {
@@ -880,7 +1535,7 @@ async function main() {
880
1535
  if (args.open) openBrowser(url);
881
1536
  if (hasData && args.sync) {
882
1537
  console.log("裏で同期しています");
883
- runSync({ dataDir, days: args.days, orgs: args.orgs, log: (line) => console.log(`[sync] ${line}`) }).catch(
1538
+ runSync({ dataDir, days: args.days, orgs: args.orgs, jira, confluence, log: (line) => console.log(`[sync] ${line}`) }).catch(
884
1539
  (err) => console.error(`同期に失敗: ${err instanceof Error ? err.message : String(err)}`)
885
1540
  );
886
1541
  }
@@ -902,6 +1557,8 @@ async function main() {
902
1557
  dataDir,
903
1558
  days: args.days,
904
1559
  orgs: args.orgs,
1560
+ jira,
1561
+ confluence,
905
1562
  mode: full ? "full" : "light",
906
1563
  log: (line) => console.log(`[watch] ${line}`)
907
1564
  }).catch((err) => console.error(`[watch] 同期に失敗: ${err instanceof Error ? err.message : String(err)}`)).finally(() => {