@mnemom/mnemom 0.17.38-next.1 → 0.17.38-next.2

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.
Files changed (66) hide show
  1. package/dist/commands/agent-run.d.ts +10 -2
  2. package/dist/commands/agent-run.js +12 -6
  3. package/dist/commands/scan.d.ts +1 -1
  4. package/dist/commands/scan.js +7 -14
  5. package/dist/lib/runner/executor-client.d.ts +6 -0
  6. package/dist/lib/runner/executor-client.js +6 -0
  7. package/dist/lib/runner/job.d.ts +12 -3
  8. package/dist/lib/runner/job.js +30 -2
  9. package/dist/lib/scan/caps.d.ts +1 -35
  10. package/dist/lib/scan/caps.js +2 -67
  11. package/dist/lib/scan/cost-per-pr.d.ts +1 -93
  12. package/dist/lib/scan/cost-per-pr.js +2 -101
  13. package/dist/lib/scan/credential.d.ts +2 -26
  14. package/dist/lib/scan/credential.js +5 -58
  15. package/dist/lib/scan/failure.d.ts +1 -10
  16. package/dist/lib/scan/failure.js +2 -43
  17. package/dist/lib/scan/pr-source.d.ts +1 -57
  18. package/dist/lib/scan/pr-source.js +2 -132
  19. package/dist/lib/scan/report-email.d.ts +7 -104
  20. package/dist/lib/scan/report-email.js +25 -97
  21. package/dist/lib/scan/scan.d.ts +1 -69
  22. package/dist/lib/scan/scan.js +2 -195
  23. package/dist/lib/scan/share.d.ts +3 -1
  24. package/dist/lib/scan/spend/ai-tool-detect.d.ts +1 -6
  25. package/dist/lib/scan/spend/ai-tool-detect.js +2 -18
  26. package/dist/lib/scan/spend/anthropic-usage.d.ts +1 -76
  27. package/dist/lib/scan/spend/anthropic-usage.js +2 -269
  28. package/dist/lib/scan/spend/prices.d.ts +1 -33
  29. package/dist/lib/scan/spend/prices.js +2 -57
  30. package/dist/lib/scan/spend/spend-estimate.d.ts +1 -30
  31. package/dist/lib/scan/spend/spend-estimate.js +2 -70
  32. package/dist/lib/scan/spend/spend-tiers.d.ts +1 -16
  33. package/dist/lib/scan/spend/spend-tiers.js +6 -26
  34. package/dist/lib/scan/spend/token-rate.d.ts +1 -30
  35. package/dist/lib/scan/spend/token-rate.js +2 -54
  36. package/dist/vendor/shared/org-scan-core/caps.d.ts +35 -0
  37. package/dist/vendor/shared/org-scan-core/caps.js +68 -0
  38. package/dist/vendor/shared/org-scan-core/cost-per-pr.d.ts +93 -0
  39. package/dist/vendor/shared/org-scan-core/cost-per-pr.js +101 -0
  40. package/dist/vendor/shared/org-scan-core/credential.d.ts +43 -0
  41. package/dist/vendor/shared/org-scan-core/credential.js +74 -0
  42. package/dist/vendor/shared/org-scan-core/failure.d.ts +10 -0
  43. package/dist/vendor/shared/org-scan-core/failure.js +43 -0
  44. package/dist/vendor/shared/org-scan-core/index.d.ts +18 -0
  45. package/dist/vendor/shared/org-scan-core/index.js +19 -0
  46. package/dist/vendor/shared/org-scan-core/pr-source.d.ts +61 -0
  47. package/dist/vendor/shared/org-scan-core/pr-source.js +138 -0
  48. package/dist/vendor/shared/org-scan-core/report-build.d.ts +120 -0
  49. package/dist/vendor/shared/org-scan-core/report-build.js +119 -0
  50. package/dist/vendor/shared/org-scan-core/scan.d.ts +75 -0
  51. package/dist/vendor/shared/org-scan-core/scan.js +202 -0
  52. package/dist/vendor/shared/org-scan-core/spend/ai-tool-detect.d.ts +6 -0
  53. package/dist/vendor/shared/org-scan-core/spend/ai-tool-detect.js +18 -0
  54. package/dist/vendor/shared/org-scan-core/spend/anthropic-usage.d.ts +76 -0
  55. package/dist/vendor/shared/org-scan-core/spend/anthropic-usage.js +270 -0
  56. package/dist/{lib/scan → vendor/shared/org-scan-core}/spend/cost-corpus.v1.js +1 -0
  57. package/dist/vendor/shared/org-scan-core/spend/prices.d.ts +33 -0
  58. package/dist/vendor/shared/org-scan-core/spend/prices.js +58 -0
  59. package/dist/vendor/shared/org-scan-core/spend/spend-estimate.d.ts +30 -0
  60. package/dist/vendor/shared/org-scan-core/spend/spend-estimate.js +71 -0
  61. package/dist/vendor/shared/org-scan-core/spend/spend-select.d.ts +16 -0
  62. package/dist/vendor/shared/org-scan-core/spend/spend-select.js +26 -0
  63. package/dist/vendor/shared/org-scan-core/spend/token-rate.d.ts +30 -0
  64. package/dist/vendor/shared/org-scan-core/spend/token-rate.js +55 -0
  65. package/package.json +1 -1
  66. /package/dist/{lib/scan → vendor/shared/org-scan-core}/spend/cost-corpus.v1.d.ts +0 -0
@@ -1,64 +1,11 @@
1
1
  /**
2
- * GitHubCredential: the one seam through which a local scan obtains a GitHub token.
3
- *
4
- * The scan core only ever sees this interface. The concrete default is the GitHub login of whoever
5
- * runs the scan (`gh auth token`, see createGhLoginCredential); the server-issued App token path is
6
- * retired (design owner, 2026-10-28). Port of the read-only rule in mnemom-api
7
- * src/org-scan/github-scan-app.ts.
2
+ * The CLI's GitHub credential. The runtime-neutral seam (GitHubCredential, the read-only rule) lives in
3
+ * shared/org-scan-core (MNE-9752) and is re-exported here; only the `gh auth token` default, which needs
4
+ * node:child_process, stays in the CLI.
8
5
  */
9
6
  import { execFile } from "node:child_process";
10
- /** The complete permission set a scan credential may hold. Read only. */
11
- export const SCAN_READ_PERMISSIONS = Object.freeze({
12
- metadata: "read",
13
- contents: "read",
14
- pull_requests: "read",
15
- });
16
- /** Violations (empty when the map is exactly the read-only scan set). Port of readOnlyViolations. */
17
- export function readOnlyViolations(permissions) {
18
- const out = [];
19
- const p = permissions ?? {};
20
- for (const [name, level] of Object.entries(p)) {
21
- if (level !== "read")
22
- out.push(`${name}:${level} is not read`);
23
- if (!(name in SCAN_READ_PERMISSIONS))
24
- out.push(`${name} is not in the scan permission set`);
25
- }
26
- for (const name of Object.keys(SCAN_READ_PERMISSIONS)) {
27
- if (!(name in p))
28
- out.push(`${name} missing`);
29
- }
30
- return out;
31
- }
32
- /** owner/name, no dot-only segment. */
33
- export function isValidRepoSlug(s) {
34
- return (typeof s === "string" &&
35
- /^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/.test(s) &&
36
- !s.split("/").some((p) => /^\.+$/.test(p)));
37
- }
38
- /** Fixed-text failure: the message never carries a token, a body or a URL. */
39
- export class ScanCredentialError extends Error {
40
- constructor(message) {
41
- super(message);
42
- this.name = "ScanCredentialError";
43
- }
44
- }
45
- /**
46
- * Refuses a credential GitHub granted more than read to. Throws a fixed-text error and nothing else.
47
- * Unknown (null) permissions pass: the source cannot tell, and the scan is GET-only regardless.
48
- */
49
- export async function assertReadOnlyCredential(c) {
50
- if (!c.grantedPermissions)
51
- return;
52
- let perms;
53
- try {
54
- perms = await c.grantedPermissions();
55
- }
56
- catch {
57
- throw new ScanCredentialError("scan credential refused");
58
- }
59
- if (perms !== null && readOnlyViolations(perms).length > 0)
60
- throw new ScanCredentialError("scan credential refused");
61
- }
7
+ import { ScanCredentialError, } from "../../vendor/shared/org-scan-core/credential.js";
8
+ export * from "../../vendor/shared/org-scan-core/credential.js";
62
9
  const runGhAuthToken = () => new Promise((resolve, reject) => {
63
10
  try {
64
11
  execFile("gh", ["auth", "token"], { shell: false, windowsHide: true, timeout: 10_000, maxBuffer: 64 * 1024 }, (err, stdout) => {
@@ -1,10 +1 @@
1
- /**
2
- * Scan failure reasons. Port of upstreamTag in mnemom-api capped-scan.ts (PRs #4221 / #4223): an
3
- * exact-match table of fixed messages to fixed reason tags. The raw error message is NEVER echoed,
4
- * so no token, URL or GitHub body can reach the terminal or the report. An unrecognised error maps
5
- * to the generic "github" tag.
6
- */
7
- export type FailureReason = "not_installed" | "credential" | "timeout" | "forbidden" | "rate_limited" | "github" | "network" | "invalid_input";
8
- export declare function classifyFailure(e: unknown): FailureReason;
9
- /** Fixed sentence for a reason. */
10
- export declare function failureText(r: FailureReason): string;
1
+ export * from "../../vendor/shared/org-scan-core/failure.js";
@@ -1,43 +1,2 @@
1
- const EXACT = new Map([
2
- ["github repo not visible", "not_installed"],
3
- ["scan credential refused", "credential"],
4
- ["scan credential not configured", "credential"],
5
- ["invalid repo", "invalid_input"],
6
- ["invalid cursor", "invalid_input"],
7
- ["scan read not allowed", "invalid_input"],
8
- ["unexpected list shape", "github"],
9
- ]);
10
- export function classifyFailure(e) {
11
- if (e instanceof Error) {
12
- if (e.name === "TimeoutError" || e.name === "AbortError")
13
- return "timeout";
14
- const exact = EXACT.get(e.message);
15
- if (exact)
16
- return exact;
17
- const gh = /^github read failed \((\d{3})\)$/.exec(e.message);
18
- if (gh) {
19
- if (gh[1] === "401" || gh[1] === "403")
20
- return "forbidden";
21
- if (gh[1] === "429")
22
- return "rate_limited";
23
- return "github";
24
- }
25
- if (e instanceof TypeError)
26
- return "network";
27
- }
28
- return "github";
29
- }
30
- const TEXT = {
31
- not_installed: "a repo is not visible to the credential (check your GitHub access to it)",
32
- credential: "the GitHub credential was refused or is not configured",
33
- timeout: "GitHub did not answer in time",
34
- forbidden: "GitHub refused the credential (401/403)",
35
- rate_limited: "GitHub rate limit reached",
36
- github: "a GitHub read failed",
37
- network: "GitHub was unreachable",
38
- invalid_input: "an input to the scan was invalid",
39
- };
40
- /** Fixed sentence for a reason. */
41
- export function failureText(r) {
42
- return TEXT[r];
43
- }
1
+ // Moved to shared/org-scan-core (MNE-9752); vendored at cli/src/vendor/shared/org-scan-core. Re-exported so CLI imports stay put.
2
+ export * from "../../vendor/shared/org-scan-core/failure.js";
@@ -1,57 +1 @@
1
- /**
2
- * Read-only GitHub PR source for the local scan. Port of mnemom-api src/org-scan/scan/github-pr-source.ts
3
- * (window path, PRs #4208 / #4221 / #4223).
4
- *
5
- * METADATA only: size counts, a CI rollup, a revert boolean and the AI tools named in the PR body.
6
- * Titles and bodies are inspected in memory and dropped; author logins, branch names, and code never
7
- * leave this module (PrRecord has no such field). Every call goes through `guardedGet`, which only
8
- * ever issues a GET to api.github.com on an allowlisted path. Failures throw fixed-text errors:
9
- * never a GitHub body, a header, a URL or the token.
10
- */
11
- import { type GitHubCredential } from "./credential.js";
12
- import type { AiTool } from "./spend/prices.js";
13
- export declare const GITHUB_API = "https://api.github.com";
14
- /** Merged PRs are listed 100 at a time; a list page is one read however many PRs it holds. */
15
- export declare const INDEX_PAGE_SIZE = 100;
16
- export type CiRollup = "none" | "pending" | "success" | "failure";
17
- export interface PrIndexItem {
18
- number: number;
19
- /** YYYY-MM-DD, UTC. */
20
- mergedDay: string;
21
- /** YYYY-MM-DD, UTC, last activity (the listing is newest-activity first). */
22
- updatedDay: string;
23
- reverted: boolean;
24
- }
25
- export interface PrIndexPage {
26
- items: PrIndexItem[];
27
- next: string | null;
28
- reads: number;
29
- }
30
- export interface PrRecord {
31
- additions: number;
32
- deletions: number;
33
- changed_files: number;
34
- ci?: CiRollup;
35
- reverted: boolean;
36
- /** AI tools named in the PR body, detected here so the body never leaves this module. */
37
- tools: AiTool[];
38
- }
39
- export interface PrSource {
40
- indexMerged(repo: string, cursor: string | null, maxReads?: number): Promise<PrIndexPage>;
41
- readPr(repo: string, item: PrIndexItem, maxReads?: number): Promise<{
42
- pr: PrRecord | null;
43
- reads: number;
44
- }>;
45
- }
46
- export interface GithubPrSourceDeps {
47
- credential: GitHubCredential;
48
- fetchImpl?: typeof fetch;
49
- timeoutMs?: number;
50
- }
51
- /** Pure: one word from check-run conclusions. No check names, logs or URLs are kept. */
52
- export declare function rollupCi(runs: Array<{
53
- status?: unknown;
54
- conclusion?: unknown;
55
- }>): CiRollup;
56
- export declare function isAllowedScanPath(path: string): boolean;
57
- export declare function createGithubPrSource(deps: GithubPrSourceDeps): PrSource;
1
+ export * from "../../vendor/shared/org-scan-core/pr-source.js";
@@ -1,132 +1,2 @@
1
- /**
2
- * Read-only GitHub PR source for the local scan. Port of mnemom-api src/org-scan/scan/github-pr-source.ts
3
- * (window path, PRs #4208 / #4221 / #4223).
4
- *
5
- * METADATA only: size counts, a CI rollup, a revert boolean and the AI tools named in the PR body.
6
- * Titles and bodies are inspected in memory and dropped; author logins, branch names, and code never
7
- * leave this module (PrRecord has no such field). Every call goes through `guardedGet`, which only
8
- * ever issues a GET to api.github.com on an allowlisted path. Failures throw fixed-text errors:
9
- * never a GitHub body, a header, a URL or the token.
10
- */
11
- import { isValidRepoSlug } from "./credential.js";
12
- import { detectAiTools } from "./spend/ai-tool-detect.js";
13
- export const GITHUB_API = "https://api.github.com";
14
- /** Merged PRs are listed 100 at a time; a list page is one read however many PRs it holds. */
15
- export const INDEX_PAGE_SIZE = 100;
16
- /** Pure: one word from check-run conclusions. No check names, logs or URLs are kept. */
17
- export function rollupCi(runs) {
18
- if (runs.length === 0)
19
- return "none";
20
- if (runs.some((r) => r.status !== "completed"))
21
- return "pending";
22
- const ok = new Set(["success", "neutral", "skipped"]);
23
- return runs.every((r) => typeof r.conclusion === "string" && ok.has(r.conclusion))
24
- ? "success"
25
- : "failure";
26
- }
27
- const isCount = (n) => typeof n === "number" && Number.isInteger(n) && n >= 0;
28
- const SLUG = "[A-Za-z0-9._-]{1,100}/[A-Za-z0-9._-]{1,100}";
29
- /** The only paths the scan may read. Anything else is refused before a request is made. */
30
- const ALLOWED_PATHS = [
31
- new RegExp(`^/repos/${SLUG}/pulls\\?state=closed&sort=updated&direction=desc&per_page=\\d{1,3}&page=\\d{1,4}$`),
32
- new RegExp(`^/repos/${SLUG}/pulls/\\d{1,9}$`),
33
- new RegExp(`^/repos/${SLUG}/commits/[0-9a-f]{40}/check-runs\\?per_page=100$`),
34
- // The repo picker's one list read (MNE-9643): the repos this login can read, newest push first.
35
- /^\/user\/repos\?sort=pushed&direction=desc&per_page=\d{1,3}$/,
36
- ];
37
- export function isAllowedScanPath(path) {
38
- return ALLOWED_PATHS.some((re) => re.test(path));
39
- }
40
- export function createGithubPrSource(deps) {
41
- const doFetch = deps.fetchImpl ?? fetch;
42
- const headers = {
43
- accept: "application/vnd.github+json",
44
- "x-github-api-version": "2022-11-28",
45
- "user-agent": "mnemom-cli-scan/1",
46
- };
47
- /**
48
- * `list` marks the PR list read, the first read of a repo. GitHub hides a repo the credential cannot
49
- * see behind a 404, so only there is a 404 "repo not visible"; a 404 on a later detail or
50
- * check-runs read is an ordinary upstream miss and keeps its numeric message (#4223).
51
- */
52
- async function guardedGet(path, repo, list = false) {
53
- if (!isAllowedScanPath(path))
54
- throw new Error("scan read not allowed");
55
- const bearer = await deps.credential.tokenFor(repo);
56
- const res = await doFetch(`${GITHUB_API}${path}`, {
57
- method: "GET",
58
- headers: { ...headers, authorization: `Bearer ${bearer}` },
59
- redirect: "error",
60
- signal: AbortSignal.timeout(deps.timeoutMs ?? 10_000),
61
- });
62
- if (!res.ok) {
63
- await res.body?.cancel().catch(() => { });
64
- throw new Error(list && res.status === 404
65
- ? "github repo not visible"
66
- : `github read failed (${res.status})`);
67
- }
68
- return res.json();
69
- }
70
- const day = (v) => {
71
- const t = typeof v === "string" ? Date.parse(v) : NaN;
72
- return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 10) : null;
73
- };
74
- return {
75
- async indexMerged(repo, cursor) {
76
- if (!isValidRepoSlug(repo))
77
- throw new Error("invalid repo");
78
- const page = cursor === null ? 1 : Number(cursor);
79
- if (!Number.isInteger(page) || page < 1 || page > 1000)
80
- throw new Error("invalid cursor");
81
- const list = (await guardedGet(`/repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=${INDEX_PAGE_SIZE}&page=${page}`, repo, true));
82
- if (!Array.isArray(list))
83
- throw new Error("unexpected list shape");
84
- const items = [];
85
- for (const it of list) {
86
- const mergedDay = day(it.merged_at);
87
- const updatedDay = day(it.updated_at);
88
- if (mergedDay === null || updatedDay === null || !isCount(it.number))
89
- continue;
90
- // The title is read for the revert marker only and dropped here.
91
- items.push({
92
- number: it.number,
93
- mergedDay,
94
- updatedDay,
95
- reverted: typeof it.title === "string" && /^revert\b/i.test(it.title),
96
- });
97
- }
98
- return { items, next: list.length < INDEX_PAGE_SIZE ? null : String(page + 1), reads: 1 };
99
- },
100
- async readPr(repo, item, maxReads = Number.POSITIVE_INFINITY) {
101
- if (!isValidRepoSlug(repo))
102
- throw new Error("invalid repo");
103
- if (!isCount(item.number) || maxReads < 2)
104
- return { pr: null, reads: 0 };
105
- const detail = (await guardedGet(`/repos/${repo}/pulls/${item.number}`, repo));
106
- let reads = 1;
107
- let ci;
108
- const sha = detail.head?.sha;
109
- if (typeof sha === "string" && /^[0-9a-f]{40}$/.test(sha)) {
110
- const checks = (await guardedGet(`/repos/${repo}/commits/${sha}/check-runs?per_page=100`, repo));
111
- reads += 1;
112
- ci = rollupCi(Array.isArray(checks.check_runs) ? checks.check_runs : []);
113
- }
114
- if (!isCount(detail.additions) ||
115
- !isCount(detail.deletions) ||
116
- !isCount(detail.changed_files))
117
- return { pr: null, reads };
118
- const tools = detectAiTools(detail.body);
119
- return {
120
- pr: {
121
- additions: detail.additions,
122
- deletions: detail.deletions,
123
- changed_files: detail.changed_files,
124
- ci,
125
- reverted: item.reverted,
126
- tools,
127
- },
128
- reads,
129
- };
130
- },
131
- };
132
- }
1
+ // Moved to shared/org-scan-core (MNE-9752); vendored at cli/src/vendor/shared/org-scan-core. Re-exported so CLI imports stay put.
2
+ export * from "../../vendor/shared/org-scan-core/pr-source.js";
@@ -1,108 +1,10 @@
1
- import { type CostPerPr, type CostSpend } from "./cost-per-pr.js";
2
- import type { ScanResult } from "./scan.js";
3
- import type { ShareLevel } from "./share.js";
4
- export declare const REPORT_SCHEMA_VERSION = "org-scan-report/1";
5
- export declare const ESTIMATE_NAME = "automatable_work_share";
6
- export declare const METHOD_URL = "https://www.mnemom.ai/org-scan-method";
7
- export interface EmailReport {
8
- schema_version: typeof REPORT_SCHEMA_VERSION;
9
- org_id: string;
10
- scan_id: string;
11
- period: {
12
- start: string;
13
- end: string;
14
- };
15
- generated_at: string;
16
- measured: {
17
- teams: [];
18
- summary: {
19
- pr_count: number;
20
- revert_count: number;
21
- automatable_count: number;
22
- examined_count: number;
23
- tokens_total: 0;
24
- };
25
- spend: {
26
- source: "pr_estimate";
27
- figure?: {
28
- low_cents: number;
29
- high_cents: number;
30
- };
31
- ai_prs_by_tool?: Array<{
32
- tool: string;
33
- count: number;
34
- }>;
35
- };
36
- };
37
- estimate: {
38
- name: string;
39
- value: number;
40
- unit: string;
41
- method_url: string;
42
- };
43
- /**
44
- * Optional, added after org-scan-report/1 shipped; absent whenever it cannot be computed (no spend,
45
- * no merged PR in the period, an incomplete scan, an empty bucket). See cost-per-pr.ts.
46
- * cost_per_merged_pr average cost of one merged PR in the period
47
- * cost_by_pr_size { small | medium | large }: average cost of ONE PR of that size, per-bucket
48
- * keys absent when the bucket is empty. small < 50 lines changed, medium 50..500, large > 500.
49
- * A figure is { label: "estimate", spend_source, pr_scope, pr_count } (+ spend_scope: "whole_org" on
50
- * anthropic_usage) plus { value_cents } (anthropic_usage) or { low_cents, high_cents } (modelled_range). Integer cents.
51
- * pr_scope is "ai_assisted" for modelled_range (spend and size buckets split across AI-assisted PRs only;
52
- * absent if none) and "all_merged" otherwise; pr_count counts PRs in that scope.
53
- * Always attached when present. The API's report validator (mnemom-api src/org-scan/report-schema.ts) is
54
- * closed, so an API that does not yet list these keys answers 400 invalid_report; sendReportEmail then
55
- * retries once without them so the report email still goes out.
56
- */
57
- cost_per_merged_pr?: CostPerPr["cost_per_merged_pr"];
58
- cost_by_pr_size?: CostPerPr["cost_by_pr_size"];
59
- /**
60
- * The per-repo counts, by name (MNE-9634). Always sent, at every share level: the emailed report is the full
61
- * report. Optional on the wire so an API that predates it can be retried without it. Capped at 500 repos
62
- * (busiest first), valid names only, each name once.
63
- */
64
- by_repo?: Array<{
65
- repo: string;
66
- pr_count: number;
67
- revert_count: number;
68
- automatable_count: number;
69
- examined_count: number;
70
- }>;
71
- }
72
- export interface ReportEmailBody {
73
- report: EmailReport;
74
- share_level: ShareLevel;
75
- coverage: {
76
- complete: boolean;
77
- };
78
- /** The extra recipients the runner added. Absent when none. */
79
- emails?: string[];
80
- /** Only set on the retry to an API that predates `emails`: it reads one extra address under this name. */
81
- cto_email?: string;
82
- }
83
- /**
84
- * Maps the local scan onto the route's closed report shape. Team totals stay empty (they come only from
85
- * Anthropic usage, which the local scan does not carry). The estimate is the share of examined merged
86
- * PRs that finished with every CI check passing. `figure` (tier 3 range) is included only when the CLI
87
- * itself would let that line leave the machine (`sendSpendRange`).
88
- */
89
- export declare function buildEmailReport(result: ScanResult, o: {
90
- orgId: string;
91
- now: () => number;
92
- sendSpendRange: boolean;
93
- scanId?: string;
94
- /** The requested scan period (the one the spend covers), not the examined window. */
95
- period?: {
96
- start: string;
97
- end: string;
98
- };
99
- /** The one spend source the cost figures are split from; null/absent leaves them out. */
100
- costSpend?: CostSpend | null;
101
- }): EmailReport;
102
- export declare function buildEmailBody(report: EmailReport, level: ShareLevel, complete: boolean, extraEmails?: string[]): ReportEmailBody;
1
+ import type { ReportEmailBody } from "../../vendor/shared/org-scan-core/report-build.js";
2
+ export { REPORT_SCHEMA_VERSION, ESTIMATE_NAME, METHOD_URL, buildEmailReport, buildEmailBody, type EmailReport, type ReportEmailBody, } from "../../vendor/shared/org-scan-core/report-build.js";
103
3
  /** Closed set of failure names; each has fixed text. */
104
- export type EmailFailure = "presend_check_failed" | "email_not_sent" | "not_signed_in" | "not_a_member" | "report_rejected" | "no_recipients" | "runner_not_emailed" | "emails_rejected" | "email_domain_not_allowed" | "admins_not_found" | "network_error" | "unexpected_response";
105
- export declare const failureMessage: (f: EmailFailure) => string;
4
+ export type EmailFailure = "presend_check_failed" | "email_not_sent" | "not_signed_in" | "not_a_member" | "report_rejected" | "no_recipients" | "runner_not_emailed" | "emails_rejected" | "email_domain_not_allowed" | "admins_not_found" | "scan_limit_reached" | "network_error" | "unexpected_response";
5
+ /** How long to wait, in plain words (rounded up to whole seconds, minutes or hours). */
6
+ export declare function retryWait(ms: number): string;
7
+ export declare const failureMessage: (f: EmailFailure, retryAfterMs?: number) => string;
106
8
  export declare const DRY_RUN_MSG = "Report email is in dry-run on this environment; nothing was sent.";
107
9
  export declare const UNAVAILABLE_MSG = "Report email is not available for this org yet; nothing was sent.";
108
10
  export declare class ReportEmailError extends Error {
@@ -121,6 +23,7 @@ export type EmailOutcome = {
121
23
  } | {
122
24
  status: "failed";
123
25
  reason: EmailFailure;
26
+ retryAfterMs?: number;
124
27
  };
125
28
  export interface ReportEmailDeps {
126
29
  post?: (endpoint: string, body: ReportEmailBody) => Promise<unknown>;
@@ -12,102 +12,9 @@
12
12
  * Every failure has a fixed name and fixed text. The server's body, the thrown value and the
13
13
  * credential never reach the terminal.
14
14
  */
15
- import { randomUUID } from "node:crypto";
16
15
  import { MnemomApiError, postApi } from "../api.js";
17
- import { buildCostPerPr } from "./cost-per-pr.js";
18
- export const REPORT_SCHEMA_VERSION = "org-scan-report/1";
19
- export const ESTIMATE_NAME = "automatable_work_share";
20
- export const METHOD_URL = "https://www.mnemom.ai/org-scan-method";
21
- const cents = (usd) => Math.max(0, Math.round(usd * 100));
22
- /**
23
- * Maps the local scan onto the route's closed report shape. Team totals stay empty (they come only from
24
- * Anthropic usage, which the local scan does not carry). The estimate is the share of examined merged
25
- * PRs that finished with every CI check passing. `figure` (tier 3 range) is included only when the CLI
26
- * itself would let that line leave the machine (`sendSpendRange`).
27
- */
28
- export function buildEmailReport(result, o) {
29
- const { totals, coverage } = result;
30
- const value = totals.examined > 0 ? Math.round((totals.ciGreen / totals.examined) * 1000) / 10 : 0;
31
- const scope = coverage.complete
32
- ? "every merged pull request in the period"
33
- : "a capped sample (not every merged pull request in the period)";
34
- const unit = `percent of merged pull requests examined (${totals.examined} of ${totals.prs}, ${scope}) ` +
35
- `that finished with all CI checks passing; an estimate from pull request metadata, not a measured share`;
36
- const byTool = result.spend.kind === "range"
37
- ? Object.entries(result.spend.byTool)
38
- .filter(([, n]) => typeof n === "number" && n > 0)
39
- .map(([tool, count]) => ({ tool, count: count }))
40
- : [];
41
- // Only a scan that read every merged PR in the period gives an average that means anything.
42
- const exhaustive = coverage.complete && coverage.prsSkippedLarge === 0 && coverage.prsUnreadable === 0;
43
- const cost = buildCostPerPr(result.prSizes, o.period ?? { start: coverage.windowStart, end: coverage.windowEnd }, o.costSpend ?? null, exhaustive);
44
- const byRepo = repoRows(result);
45
- return {
46
- schema_version: REPORT_SCHEMA_VERSION,
47
- org_id: o.orgId,
48
- scan_id: o.scanId ?? randomUUID(),
49
- // The span the scan actually examined (coverage), never the requested period: with no --period
50
- // that defaults to today..today, which is not what was read.
51
- period: { start: coverage.windowStart, end: coverage.windowEnd },
52
- generated_at: new Date(o.now()).toISOString(),
53
- measured: {
54
- teams: [],
55
- summary: {
56
- pr_count: totals.prs,
57
- revert_count: totals.reverts,
58
- automatable_count: totals.ciGreen,
59
- examined_count: totals.examined,
60
- tokens_total: 0,
61
- },
62
- spend: {
63
- source: "pr_estimate",
64
- ...(o.sendSpendRange && result.spend.kind === "range"
65
- ? {
66
- figure: {
67
- low_cents: cents(result.spend.lowUsd),
68
- high_cents: cents(result.spend.highUsd),
69
- },
70
- }
71
- : {}),
72
- ...(o.sendSpendRange && byTool.length > 0 ? { ai_prs_by_tool: byTool } : {}),
73
- },
74
- },
75
- estimate: { name: ESTIMATE_NAME, value, unit, method_url: METHOD_URL },
76
- ...cost,
77
- ...(byRepo.length > 0 ? { by_repo: byRepo } : {}),
78
- };
79
- }
80
- const MAX_REPOS = 500;
81
- const REPO_NAME = /^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/;
82
- /** The per-repo rows for the report: valid names only, each once (any case), busiest first, at most 500. */
83
- function repoRows(result) {
84
- const seen = new Set();
85
- return [...result.byRepo]
86
- .sort((a, b) => b.prs - a.prs || a.repo.localeCompare(b.repo))
87
- .filter((r) => {
88
- const k = r.repo.toLowerCase();
89
- if (!REPO_NAME.test(r.repo) || seen.has(k))
90
- return false;
91
- seen.add(k);
92
- return true;
93
- })
94
- .slice(0, MAX_REPOS)
95
- .map((r) => ({
96
- repo: r.repo,
97
- pr_count: r.prs,
98
- revert_count: r.reverts,
99
- automatable_count: r.ciGreen,
100
- examined_count: r.examined,
101
- }));
102
- }
103
- export function buildEmailBody(report, level, complete, extraEmails = []) {
104
- return {
105
- report,
106
- share_level: level,
107
- coverage: { complete },
108
- ...(extraEmails.length > 0 ? { emails: extraEmails } : {}),
109
- };
110
- }
16
+ // The report body is built by the shared core (MNE-9752), so the Mnemom-side run sends the same report.
17
+ export { REPORT_SCHEMA_VERSION, ESTIMATE_NAME, METHOD_URL, buildEmailReport, buildEmailBody, } from "../../vendor/shared/org-scan-core/report-build.js";
111
18
  const FAILURE_TEXT = {
112
19
  presend_check_failed: "the report failed Mnemom's pre-send checks, so no email was sent.",
113
20
  email_not_sent: "Mnemom could not send the email.",
@@ -119,10 +26,26 @@ const FAILURE_TEXT = {
119
26
  emails_rejected: "an address you added was not accepted. Add up to 10 valid email addresses.",
120
27
  email_domain_not_allowed: "an address you added is not on your organization's email domain. Add addresses on your own domain or mnemom.ai.",
121
28
  admins_not_found: "Mnemom could not look up your org admins, so no email was sent. Try again in a moment.",
29
+ scan_limit_reached: "the scan limit was reached, so no email was sent.",
122
30
  network_error: "Mnemom could not be reached.",
123
31
  unexpected_response: "Mnemom answered in an unexpected way.",
124
32
  };
125
- export const failureMessage = (f) => `Your report was NOT emailed: ${f}. ${FAILURE_TEXT[f]}`;
33
+ /** How long to wait, in plain words (rounded up to whole seconds, minutes or hours). */
34
+ export function retryWait(ms) {
35
+ const s = Math.max(1, Math.ceil(ms / 1000));
36
+ if (s < 60)
37
+ return `${s} second${s === 1 ? "" : "s"}`;
38
+ const m = Math.ceil(s / 60);
39
+ if (m < 120)
40
+ return `${m} minute${m === 1 ? "" : "s"}`;
41
+ const h = Math.ceil(m / 60);
42
+ return `${h} hour${h === 1 ? "" : "s"}`;
43
+ }
44
+ export const failureMessage = (f, retryAfterMs) => f === "scan_limit_reached"
45
+ ? `Your report was NOT emailed: ${f}. ${FAILURE_TEXT[f]} ${retryAfterMs !== undefined
46
+ ? `Try again in about ${retryWait(retryAfterMs)}.`
47
+ : "Try again later."}`
48
+ : `Your report was NOT emailed: ${f}. ${FAILURE_TEXT[f]}`;
126
49
  export const DRY_RUN_MSG = "Report email is in dry-run on this environment; nothing was sent.";
127
50
  export const UNAVAILABLE_MSG = "Report email is not available for this org yet; nothing was sent.";
128
51
  export class ReportEmailError extends Error {
@@ -139,6 +62,8 @@ function classifyStatus(status) {
139
62
  return "not_signed_in";
140
63
  if (status === 403)
141
64
  return "not_a_member";
65
+ if (status === 429)
66
+ return "scan_limit_reached";
142
67
  if (status === 422)
143
68
  return "presend_check_failed";
144
69
  if (status === 502)
@@ -199,7 +124,10 @@ export async function sendReportEmail(orgId, body, deps = {}) {
199
124
  return { status: "failed", reason: "admins_not_found" };
200
125
  if (e.effectiveStatus >= 400 && e.effectiveStatus < 500 && /runner/i.test(e.code ?? ""))
201
126
  return { status: "failed", reason: "runner_not_emailed" };
202
- return { status: "failed", reason: classifyStatus(e.effectiveStatus) };
127
+ const reason = classifyStatus(e.effectiveStatus);
128
+ return reason === "scan_limit_reached" && e.retryAfterMs !== undefined
129
+ ? { status: "failed", reason, retryAfterMs: e.retryAfterMs }
130
+ : { status: "failed", reason };
203
131
  }
204
132
  return { status: "failed", reason: "network_error" };
205
133
  }
@@ -1,69 +1 @@
1
- /**
2
- * Local capped scan core. Port of the window path of runCappedScan (mnemom-api capped-scan.ts, PR #4208)
3
- * plus the spend estimate (#4222). Everything outside is injected (credential, PR source, clock), so the
4
- * core touches no network or disk, and the report exists only in memory.
5
- */
6
- import { type GitHubCredential } from "./credential.js";
7
- import { type ScanCaps, type StopReason } from "./caps.js";
8
- import { type FailureReason } from "./failure.js";
9
- import { type PrSource } from "./pr-source.js";
10
- import type { PrSize } from "./cost-per-pr.js";
11
- import { type SpendEstimate } from "./spend/spend-estimate.js";
12
- export interface ScanInput {
13
- repos: string[];
14
- /** Inclusive dates, YYYY-MM-DD. */
15
- period: {
16
- start: string;
17
- end: string;
18
- };
19
- }
20
- export interface ScanDeps {
21
- credential: GitHubCredential;
22
- prs?: PrSource;
23
- fetchImpl?: typeof fetch;
24
- now?: () => number;
25
- caps?: Partial<ScanCaps>;
26
- }
27
- export interface Coverage {
28
- complete: boolean;
29
- stoppedBy: StopReason | null;
30
- prsExamined: number;
31
- prsSkippedLarge: number;
32
- prsUnreadable: number;
33
- reposRead: number;
34
- reposTotal: number;
35
- reads: number;
36
- windowStart: string;
37
- windowEnd: string;
38
- /** Set only when the window held more merged PRs than the quota and an even sample was taken. */
39
- windowSampledFrom?: number;
40
- }
41
- export interface RepoTotals {
42
- repo: string;
43
- prs: number;
44
- reverts: number;
45
- ciGreen: number;
46
- /** PRs read and counted (not reverts, not skipped); the per-repo base of the CI-green share. */
47
- examined: number;
48
- }
49
- export interface ScanResult {
50
- coverage: Coverage;
51
- totals: {
52
- prs: number;
53
- reverts: number;
54
- ciGreen: number;
55
- examined: number;
56
- };
57
- byRepo: RepoTotals[];
58
- spend: SpendEstimate;
59
- /** Every merged PR read in full (reverts and unreadable/oversize PRs excluded): merge day and size. In memory only. */
60
- prSizes: PrSize[];
61
- }
62
- export type ScanOutcome = {
63
- status: "done";
64
- result: ScanResult;
65
- } | {
66
- status: "withheld";
67
- reason: FailureReason;
68
- };
69
- export declare function runLocalScan(deps: ScanDeps, input: ScanInput): Promise<ScanOutcome>;
1
+ export * from "../../vendor/shared/org-scan-core/scan.js";