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

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 (82) hide show
  1. package/dist/commands/agent-run.d.ts +10 -2
  2. package/dist/commands/agent-run.js +18 -7
  3. package/dist/commands/scan.d.ts +1 -1
  4. package/dist/commands/scan.js +7 -14
  5. package/dist/index.js +1 -0
  6. package/dist/lib/runner/executor-client.d.ts +6 -0
  7. package/dist/lib/runner/executor-client.js +6 -0
  8. package/dist/lib/runner/job.d.ts +12 -3
  9. package/dist/lib/runner/job.js +30 -2
  10. package/dist/lib/runner/loop.d.ts +7 -2
  11. package/dist/lib/runner/loop.js +15 -3
  12. package/dist/lib/runner/options.d.ts +7 -0
  13. package/dist/lib/runner/options.js +11 -0
  14. package/dist/lib/scan/caps.d.ts +1 -35
  15. package/dist/lib/scan/caps.js +2 -67
  16. package/dist/lib/scan/cost-per-pr.d.ts +1 -93
  17. package/dist/lib/scan/cost-per-pr.js +2 -101
  18. package/dist/lib/scan/credential.d.ts +2 -26
  19. package/dist/lib/scan/credential.js +5 -58
  20. package/dist/lib/scan/failure.d.ts +1 -10
  21. package/dist/lib/scan/failure.js +2 -43
  22. package/dist/lib/scan/pr-source.d.ts +1 -57
  23. package/dist/lib/scan/pr-source.js +2 -132
  24. package/dist/lib/scan/report-email.d.ts +7 -104
  25. package/dist/lib/scan/report-email.js +25 -97
  26. package/dist/lib/scan/scan.d.ts +1 -69
  27. package/dist/lib/scan/scan.js +2 -195
  28. package/dist/lib/scan/share.d.ts +3 -1
  29. package/dist/lib/scan/spend/ai-tool-detect.d.ts +1 -6
  30. package/dist/lib/scan/spend/ai-tool-detect.js +2 -18
  31. package/dist/lib/scan/spend/anthropic-usage.d.ts +1 -76
  32. package/dist/lib/scan/spend/anthropic-usage.js +2 -269
  33. package/dist/lib/scan/spend/prices.d.ts +1 -33
  34. package/dist/lib/scan/spend/prices.js +2 -57
  35. package/dist/lib/scan/spend/spend-estimate.d.ts +1 -30
  36. package/dist/lib/scan/spend/spend-estimate.js +2 -70
  37. package/dist/lib/scan/spend/spend-tiers.d.ts +1 -16
  38. package/dist/lib/scan/spend/spend-tiers.js +6 -26
  39. package/dist/lib/scan/spend/token-rate.d.ts +1 -30
  40. package/dist/lib/scan/spend/token-rate.js +2 -54
  41. package/dist/vendor/durable-contract/index.js +19 -2
  42. package/dist/vendor/shared/durable-contract/cli-surface.js +2 -1
  43. package/dist/vendor/shared/durable-contract/codes.d.ts +12 -0
  44. package/dist/vendor/shared/durable-contract/codes.js +11 -0
  45. package/dist/vendor/shared/durable-contract/constants.d.ts +7 -1
  46. package/dist/vendor/shared/durable-contract/constants.js +7 -1
  47. package/dist/vendor/shared/durable-contract/doors.d.ts +9 -1
  48. package/dist/vendor/shared/durable-contract/doors.js +13 -0
  49. package/dist/vendor/shared/durable-contract/freeze-record.d.ts +1 -1
  50. package/dist/vendor/shared/durable-contract/schema.d.ts +9 -0
  51. package/dist/vendor/shared/durable-contract/schema.js +11 -0
  52. package/dist/vendor/shared/org-scan-core/caps.d.ts +35 -0
  53. package/dist/vendor/shared/org-scan-core/caps.js +68 -0
  54. package/dist/vendor/shared/org-scan-core/cost-per-pr.d.ts +93 -0
  55. package/dist/vendor/shared/org-scan-core/cost-per-pr.js +101 -0
  56. package/dist/vendor/shared/org-scan-core/credential.d.ts +43 -0
  57. package/dist/vendor/shared/org-scan-core/credential.js +74 -0
  58. package/dist/vendor/shared/org-scan-core/failure.d.ts +10 -0
  59. package/dist/vendor/shared/org-scan-core/failure.js +43 -0
  60. package/dist/vendor/shared/org-scan-core/index.d.ts +18 -0
  61. package/dist/vendor/shared/org-scan-core/index.js +19 -0
  62. package/dist/vendor/shared/org-scan-core/pr-source.d.ts +61 -0
  63. package/dist/vendor/shared/org-scan-core/pr-source.js +138 -0
  64. package/dist/vendor/shared/org-scan-core/report-build.d.ts +120 -0
  65. package/dist/vendor/shared/org-scan-core/report-build.js +119 -0
  66. package/dist/vendor/shared/org-scan-core/scan.d.ts +75 -0
  67. package/dist/vendor/shared/org-scan-core/scan.js +202 -0
  68. package/dist/vendor/shared/org-scan-core/spend/ai-tool-detect.d.ts +6 -0
  69. package/dist/vendor/shared/org-scan-core/spend/ai-tool-detect.js +18 -0
  70. package/dist/vendor/shared/org-scan-core/spend/anthropic-usage.d.ts +76 -0
  71. package/dist/vendor/shared/org-scan-core/spend/anthropic-usage.js +270 -0
  72. package/dist/{lib/scan → vendor/shared/org-scan-core}/spend/cost-corpus.v1.js +1 -0
  73. package/dist/vendor/shared/org-scan-core/spend/prices.d.ts +33 -0
  74. package/dist/vendor/shared/org-scan-core/spend/prices.js +58 -0
  75. package/dist/vendor/shared/org-scan-core/spend/spend-estimate.d.ts +30 -0
  76. package/dist/vendor/shared/org-scan-core/spend/spend-estimate.js +71 -0
  77. package/dist/vendor/shared/org-scan-core/spend/spend-select.d.ts +16 -0
  78. package/dist/vendor/shared/org-scan-core/spend/spend-select.js +26 -0
  79. package/dist/vendor/shared/org-scan-core/spend/token-rate.d.ts +30 -0
  80. package/dist/vendor/shared/org-scan-core/spend/token-rate.js +55 -0
  81. package/package.json +1 -1
  82. /package/dist/{lib/scan → vendor/shared/org-scan-core}/spend/cost-corpus.v1.d.ts +0 -0
@@ -1,101 +1,2 @@
1
- /** Strictly below this is small. 50 is medium. */
2
- export const SMALL_MAX_EXCLUSIVE = 50;
3
- /** At or below this (and not small) is medium. 500 is medium; 501 is large. */
4
- export const MEDIUM_MAX_INCLUSIVE = 500;
5
- export const sizeBucket = (lines) => lines < SMALL_MAX_EXCLUSIVE ? "small" : lines <= MEDIUM_MAX_INCLUSIVE ? "medium" : "large";
6
- const DAY_MS = 86_400_000;
7
- /** Inclusive day count of YYYY-MM-DD..YYYY-MM-DD, or null if unparsable or reversed. */
8
- export function periodDays(p) {
9
- const s = Date.parse(`${p.start}T00:00:00Z`);
10
- const e = Date.parse(`${p.end}T00:00:00Z`);
11
- if (!Number.isFinite(s) || !Number.isFinite(e) || e < s)
12
- return null;
13
- return Math.round((e - s) / DAY_MS) + 1;
14
- }
15
- const pos = (n) => typeof n === "number" && Number.isFinite(n) && n > 0;
16
- /** The period's spend in USD as [low, high]; null when there is no usable spend. */
17
- export function spendForPeriod(spend) {
18
- let r;
19
- if (spend.source === "anthropic_usage") {
20
- if (!pos(spend.usd))
21
- return null;
22
- r = [spend.usd, spend.usd];
23
- }
24
- else {
25
- if (!pos(spend.highUsd) || !(spend.lowUsd >= 0) || spend.lowUsd > spend.highUsd)
26
- return null;
27
- r = [spend.lowUsd, spend.highUsd];
28
- }
29
- return r.every((x) => Number.isFinite(x) && x * 100 <= Number.MAX_SAFE_INTEGER) ? r : null;
30
- }
31
- /** The modelled estimate as a spend source, or null when the estimate is "none". */
32
- export const modelledSpend = (e) => e.kind === "range" ? { source: "modelled_range", lowUsd: e.lowUsd, highUsd: e.highUsd } : null;
33
- /**
34
- * `share` is the fraction of the period's spend one average PR here carries: always in (0, 1] by
35
- * construction, and the spend is already bounded to safe-integer cents by spendForPeriod, so the
36
- * rounded cents are finite safe integers without a further guard.
37
- */
38
- function figure(spend, [low, high], count, share, scope) {
39
- const c = (usd) => Math.round(usd * share * 100);
40
- const base = {
41
- label: "estimate",
42
- spend_source: spend.source,
43
- pr_scope: scope,
44
- pr_count: count,
45
- ...(spend.source === "anthropic_usage" ? { spend_scope: "whole_org" } : {}),
46
- };
47
- return spend.source === "modelled_range"
48
- ? { ...base, low_cents: c(low), high_cents: c(high) }
49
- : { ...base, value_cents: c(low) };
50
- }
51
- /**
52
- * @param prs every merged PR the scan read, with merge day and size
53
- * @param exhaustive true only if the scan read EVERY merged PR in the period (no sample, no skipped
54
- * large PR, none unreadable, no cap hit). Otherwise the average is not defined.
55
- */
56
- export function buildCostPerPr(prs, period, spend, exhaustive) {
57
- if (!exhaustive || !spend || !Array.isArray(prs))
58
- return {};
59
- if (periodDays(period) === null)
60
- return {};
61
- const total = spendForPeriod(spend);
62
- if (!total)
63
- return {};
64
- const inPeriod = prs.filter((p) => p.mergedDay >= period.start &&
65
- p.mergedDay <= period.end &&
66
- Number.isSafeInteger(p.lines) &&
67
- p.lines >= 0);
68
- // The modelled estimate is AI-tool spend, so it is split across AI-assisted PRs only; the usage key is
69
- // the whole spend, so it is split across every merged PR.
70
- const scope = spend.source === "modelled_range" ? "ai_assisted" : "all_merged";
71
- const scoped = scope === "ai_assisted" ? inPeriod.filter((p) => p.aiAssisted) : inPeriod;
72
- const n = scoped.length;
73
- if (n === 0)
74
- return {};
75
- const out = {};
76
- out.cost_per_merged_pr = figure(spend, total, n, 1 / n, scope);
77
- let allLines = 0;
78
- const agg = {
79
- small: { n: 0, lines: 0 },
80
- medium: { n: 0, lines: 0 },
81
- large: { n: 0, lines: 0 },
82
- };
83
- for (const p of scoped) {
84
- allLines += p.lines;
85
- const a = agg[sizeBucket(p.lines)];
86
- a.n += 1;
87
- a.lines += p.lines;
88
- }
89
- // Splitting by lines needs lines: with none (or a sum past safe range) there is no size figure.
90
- if (allLines <= 0 || !Number.isSafeInteger(allLines))
91
- return out;
92
- const bySize = {};
93
- for (const b of ["small", "medium", "large"]) {
94
- const a = agg[b];
95
- if (a.n === 0)
96
- continue;
97
- bySize[b] = figure(spend, total, a.n, a.lines / a.n / allLines, scope);
98
- }
99
- out.cost_by_pr_size = bySize;
100
- return out;
101
- }
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/cost-per-pr.js";
@@ -1,29 +1,5 @@
1
- /** The complete permission set a scan credential may hold. Read only. */
2
- export declare const SCAN_READ_PERMISSIONS: Readonly<Record<string, "read">>;
3
- export interface GitHubCredential {
4
- /** Fixed label for diagnostics ("app", "personal"): never a token, login or URL. */
5
- readonly kind: string;
6
- /** A bearer token for reading `repo` (owner/name). A rejection fails that read closed. */
7
- tokenFor(repo: string): Promise<string>;
8
- /**
9
- * The permissions GitHub granted this credential, when the source can tell. Null = unknown (for
10
- * example a classic token); the scan then relies on GET-only egress and does not claim more.
11
- */
12
- grantedPermissions?(): Promise<Record<string, string> | null>;
13
- }
14
- /** Violations (empty when the map is exactly the read-only scan set). Port of readOnlyViolations. */
15
- export declare function readOnlyViolations(permissions: Record<string, string> | null | undefined): string[];
16
- /** owner/name, no dot-only segment. */
17
- export declare function isValidRepoSlug(s: unknown): s is string;
18
- /** Fixed-text failure: the message never carries a token, a body or a URL. */
19
- export declare class ScanCredentialError extends Error {
20
- constructor(message: "scan credential refused" | "scan credential not configured");
21
- }
22
- /**
23
- * Refuses a credential GitHub granted more than read to. Throws a fixed-text error and nothing else.
24
- * Unknown (null) permissions pass: the source cannot tell, and the scan is GET-only regardless.
25
- */
26
- export declare function assertReadOnlyCredential(c: GitHubCredential): Promise<void>;
1
+ import { type GitHubCredential } from "../../vendor/shared/org-scan-core/credential.js";
2
+ export * from "../../vendor/shared/org-scan-core/credential.js";
27
3
  /** Runs `gh auth token`; resolves stdout or rejects. Injectable so tests never spawn a process. */
28
4
  export type GhTokenRunner = () => Promise<string>;
29
5
  /**
@@ -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>;