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/DESIGN.md +13 -4
- package/README.md +58 -0
- package/dist/assets/index-B3RBEh9j.js +90 -0
- package/dist/assets/index-R2XOh3Es.css +1 -0
- package/dist/index.html +2 -2
- package/dist-cli/cli.js +665 -8
- package/package.json +1 -1
- package/dist/assets/index-CwLJknHY.css +0 -1
- package/dist/assets/index-DwMah6Dr.js +0 -81
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
|
|
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(() => {
|