@mnemom/mnemom 0.17.33 → 0.17.34-next.1

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 (47) hide show
  1. package/dist/commands/agent-share-render.d.ts +1 -1
  2. package/dist/commands/agent-share-render.js +1 -1
  3. package/dist/commands/agent.d.ts +15 -0
  4. package/dist/commands/agent.js +83 -7
  5. package/dist/commands/protection.js +1 -1
  6. package/dist/commands/scan.d.ts +97 -0
  7. package/dist/commands/scan.js +238 -0
  8. package/dist/index.js +35 -0
  9. package/dist/lib/api.d.ts +13 -0
  10. package/dist/lib/api.js +8 -0
  11. package/dist/lib/scan/caps.d.ts +35 -0
  12. package/dist/lib/scan/caps.js +67 -0
  13. package/dist/lib/scan/credential.d.ts +37 -0
  14. package/dist/lib/scan/credential.js +104 -0
  15. package/dist/lib/scan/failure.d.ts +10 -0
  16. package/dist/lib/scan/failure.js +43 -0
  17. package/dist/lib/scan/pr-source.d.ts +57 -0
  18. package/dist/lib/scan/pr-source.js +130 -0
  19. package/dist/lib/scan/record.d.ts +49 -0
  20. package/dist/lib/scan/record.js +85 -0
  21. package/dist/lib/scan/report-email.d.ts +92 -0
  22. package/dist/lib/scan/report-email.js +136 -0
  23. package/dist/lib/scan/report.d.ts +13 -0
  24. package/dist/lib/scan/report.js +30 -0
  25. package/dist/lib/scan/scan.d.ts +64 -0
  26. package/dist/lib/scan/scan.js +182 -0
  27. package/dist/lib/scan/share.d.ts +38 -0
  28. package/dist/lib/scan/share.js +47 -0
  29. package/dist/lib/scan/spend/ai-tool-detect.d.ts +6 -0
  30. package/dist/lib/scan/spend/ai-tool-detect.js +18 -0
  31. package/dist/lib/scan/spend/anthropic-usage.d.ts +57 -0
  32. package/dist/lib/scan/spend/anthropic-usage.js +244 -0
  33. package/dist/lib/scan/spend/cost-corpus.v1.d.ts +56 -0
  34. package/dist/lib/scan/spend/cost-corpus.v1.js +64 -0
  35. package/dist/lib/scan/spend/prices.d.ts +33 -0
  36. package/dist/lib/scan/spend/prices.js +57 -0
  37. package/dist/lib/scan/spend/spend-estimate.d.ts +30 -0
  38. package/dist/lib/scan/spend/spend-estimate.js +70 -0
  39. package/dist/lib/scan/spend/spend-tiers.d.ts +25 -0
  40. package/dist/lib/scan/spend/spend-tiers.js +42 -0
  41. package/dist/lib/scan/spend/token-rate.d.ts +30 -0
  42. package/dist/lib/scan/spend/token-rate.js +54 -0
  43. package/dist/lib/session-key.d.ts +41 -0
  44. package/dist/lib/session-key.js +124 -0
  45. package/dist/rc-key-401.mjs +75 -0
  46. package/dist/rc-proxy.mjs +221 -25
  47. package/package.json +2 -2
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Coverage caps and sampling. Port of mnemom-api src/org-scan/scan/capped-scan.ts (PR #4208).
3
+ *
4
+ * Coverage decision (Shraddha, 2026-10-28): each scan covers the last 3 months OR the last 2,000 merged
5
+ * PRs, whichever is MORE; above 2,000 in the window, an even deterministic sample of 2,000.
6
+ */
7
+ export declare const COVERAGE_MONTHS = 3;
8
+ export interface ScanCaps {
9
+ maxPrs: number;
10
+ /** A PR with more additions+deletions than this is skipped, never read in full. */
11
+ maxPrSizeLines: number;
12
+ /** Upstream read calls. */
13
+ maxReads: number;
14
+ /** Wall-clock budget. */
15
+ maxMillis: number;
16
+ }
17
+ export declare const DEFAULT_SCAN_CAPS: ScanCaps;
18
+ export type StopReason = "pr-cap" | "read-cap" | "time-cap";
19
+ /** The day `months` calendar months before `end` (YYYY-MM-DD, UTC), clamped to the month's last day. */
20
+ export declare function monthsBefore(end: string, months: number): string;
21
+ /** `k` items spread evenly across `items` (order kept): index floor(i*n/k). Deterministic; n <= k returns all. */
22
+ export declare function evenSample<T>(items: readonly T[], k: number): T[];
23
+ export declare function sanitiseCaps(c: Partial<ScanCaps>): ScanCaps;
24
+ /** Running read budget. Every call is told how many reads it may spend (`room`). */
25
+ export declare class Budget {
26
+ private caps;
27
+ private now;
28
+ private startedAt;
29
+ reads: number;
30
+ constructor(caps: ScanCaps, now: () => number, startedAt: number);
31
+ block(n?: number): StopReason | null;
32
+ room(): number;
33
+ overrun(): StopReason | null;
34
+ charge(n: number): void;
35
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Coverage caps and sampling. Port of mnemom-api src/org-scan/scan/capped-scan.ts (PR #4208).
3
+ *
4
+ * Coverage decision (Shraddha, 2026-10-28): each scan covers the last 3 months OR the last 2,000 merged
5
+ * PRs, whichever is MORE; above 2,000 in the window, an even deterministic sample of 2,000.
6
+ */
7
+ export const COVERAGE_MONTHS = 3;
8
+ export const DEFAULT_SCAN_CAPS = {
9
+ maxPrs: 2_000,
10
+ maxPrSizeLines: 20_000,
11
+ maxReads: 4_500,
12
+ maxMillis: 15 * 60_000,
13
+ };
14
+ /** The day `months` calendar months before `end` (YYYY-MM-DD, UTC), clamped to the month's last day. */
15
+ export function monthsBefore(end, months) {
16
+ const [y, m, d] = end.split("-").map(Number);
17
+ const target = new Date(Date.UTC(y, m - 1 - months, 1));
18
+ const last = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate();
19
+ target.setUTCDate(Math.min(d, last));
20
+ return target.toISOString().slice(0, 10);
21
+ }
22
+ /** `k` items spread evenly across `items` (order kept): index floor(i*n/k). Deterministic; n <= k returns all. */
23
+ export function evenSample(items, k) {
24
+ const n = items.length;
25
+ if (n <= k)
26
+ return [...items];
27
+ return Array.from({ length: k }, (_, i) => items[Math.floor((i * n) / k)]);
28
+ }
29
+ export function sanitiseCaps(c) {
30
+ const pos = (n, d) => typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.floor(n) : d;
31
+ return {
32
+ maxPrs: pos(c.maxPrs, DEFAULT_SCAN_CAPS.maxPrs),
33
+ maxPrSizeLines: pos(c.maxPrSizeLines, DEFAULT_SCAN_CAPS.maxPrSizeLines),
34
+ maxReads: pos(c.maxReads, DEFAULT_SCAN_CAPS.maxReads),
35
+ maxMillis: pos(c.maxMillis, DEFAULT_SCAN_CAPS.maxMillis),
36
+ };
37
+ }
38
+ /** Running read budget. Every call is told how many reads it may spend (`room`). */
39
+ export class Budget {
40
+ caps;
41
+ now;
42
+ startedAt;
43
+ reads = 0;
44
+ constructor(caps, now, startedAt) {
45
+ this.caps = caps;
46
+ this.now = now;
47
+ this.startedAt = startedAt;
48
+ }
49
+ block(n = 1) {
50
+ if (this.now() - this.startedAt >= this.caps.maxMillis)
51
+ return "time-cap";
52
+ if (this.reads + n > this.caps.maxReads)
53
+ return "read-cap";
54
+ return null;
55
+ }
56
+ room() {
57
+ if (this.now() - this.startedAt >= this.caps.maxMillis)
58
+ return 0;
59
+ return Math.max(0, this.caps.maxReads - this.reads);
60
+ }
61
+ overrun() {
62
+ return this.reads > this.caps.maxReads ? "read-cap" : null;
63
+ }
64
+ charge(n) {
65
+ this.reads += n;
66
+ }
67
+ }
@@ -0,0 +1,37 @@
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>;
27
+ /** Runs `gh auth token`; resolves stdout or rejects. Injectable so tests never spawn a process. */
28
+ export type GhTokenRunner = () => Promise<string>;
29
+ /**
30
+ * The credential of whoever runs the scan: their own `gh` login. The token is fetched per read, held
31
+ * only in memory, never logged. Any failure (gh missing, not logged in, empty output) is the fixed
32
+ * "scan credential not configured"; the underlying error text is dropped.
33
+ */
34
+ export declare function createGhLoginCredential(run?: GhTokenRunner): GitHubCredential;
35
+ /** Overrides how a credential is obtained (tests, alternate sources). Null restores the gh-login default. */
36
+ export declare function registerCredentialProvider(p: (() => Promise<GitHubCredential>) | null): void;
37
+ export declare function resolveCredential(): Promise<GitHubCredential>;
@@ -0,0 +1,104 @@
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.
8
+ */
9
+ 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
+ }
62
+ const runGhAuthToken = () => new Promise((resolve, reject) => {
63
+ try {
64
+ execFile("gh", ["auth", "token"], { shell: false, windowsHide: true, timeout: 10_000, maxBuffer: 64 * 1024 }, (err, stdout) => {
65
+ if (err)
66
+ reject(new Error("gh unavailable"));
67
+ else
68
+ resolve(String(stdout));
69
+ });
70
+ }
71
+ catch {
72
+ reject(new Error("gh unavailable"));
73
+ }
74
+ });
75
+ /**
76
+ * The credential of whoever runs the scan: their own `gh` login. The token is fetched per read, held
77
+ * only in memory, never logged. Any failure (gh missing, not logged in, empty output) is the fixed
78
+ * "scan credential not configured"; the underlying error text is dropped.
79
+ */
80
+ export function createGhLoginCredential(run = runGhAuthToken) {
81
+ return {
82
+ kind: "gh-login",
83
+ async tokenFor() {
84
+ let out;
85
+ try {
86
+ out = (await run()).trim();
87
+ }
88
+ catch {
89
+ throw new ScanCredentialError("scan credential not configured");
90
+ }
91
+ if (!/^[A-Za-z0-9_.-]{10,}$/.test(out))
92
+ throw new ScanCredentialError("scan credential not configured");
93
+ return out;
94
+ },
95
+ };
96
+ }
97
+ let provider = null;
98
+ /** Overrides how a credential is obtained (tests, alternate sources). Null restores the gh-login default. */
99
+ export function registerCredentialProvider(p) {
100
+ provider = p;
101
+ }
102
+ export async function resolveCredential() {
103
+ return provider ? provider() : createGhLoginCredential();
104
+ }
@@ -0,0 +1,10 @@
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;
@@ -0,0 +1,43 @@
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
+ }
@@ -0,0 +1,57 @@
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;
@@ -0,0 +1,130 @@
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
+ ];
35
+ export function isAllowedScanPath(path) {
36
+ return ALLOWED_PATHS.some((re) => re.test(path));
37
+ }
38
+ export function createGithubPrSource(deps) {
39
+ const doFetch = deps.fetchImpl ?? fetch;
40
+ const headers = {
41
+ accept: "application/vnd.github+json",
42
+ "x-github-api-version": "2022-11-28",
43
+ "user-agent": "mnemom-cli-scan/1",
44
+ };
45
+ /**
46
+ * `list` marks the PR list read, the first read of a repo. GitHub hides a repo the credential cannot
47
+ * see behind a 404, so only there is a 404 "repo not visible"; a 404 on a later detail or
48
+ * check-runs read is an ordinary upstream miss and keeps its numeric message (#4223).
49
+ */
50
+ async function guardedGet(path, repo, list = false) {
51
+ if (!isAllowedScanPath(path))
52
+ throw new Error("scan read not allowed");
53
+ const bearer = await deps.credential.tokenFor(repo);
54
+ const res = await doFetch(`${GITHUB_API}${path}`, {
55
+ method: "GET",
56
+ headers: { ...headers, authorization: `Bearer ${bearer}` },
57
+ redirect: "error",
58
+ signal: AbortSignal.timeout(deps.timeoutMs ?? 10_000),
59
+ });
60
+ if (!res.ok) {
61
+ await res.body?.cancel().catch(() => { });
62
+ throw new Error(list && res.status === 404
63
+ ? "github repo not visible"
64
+ : `github read failed (${res.status})`);
65
+ }
66
+ return res.json();
67
+ }
68
+ const day = (v) => {
69
+ const t = typeof v === "string" ? Date.parse(v) : NaN;
70
+ return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 10) : null;
71
+ };
72
+ return {
73
+ async indexMerged(repo, cursor) {
74
+ if (!isValidRepoSlug(repo))
75
+ throw new Error("invalid repo");
76
+ const page = cursor === null ? 1 : Number(cursor);
77
+ if (!Number.isInteger(page) || page < 1 || page > 1000)
78
+ throw new Error("invalid cursor");
79
+ const list = (await guardedGet(`/repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=${INDEX_PAGE_SIZE}&page=${page}`, repo, true));
80
+ if (!Array.isArray(list))
81
+ throw new Error("unexpected list shape");
82
+ const items = [];
83
+ for (const it of list) {
84
+ const mergedDay = day(it.merged_at);
85
+ const updatedDay = day(it.updated_at);
86
+ if (mergedDay === null || updatedDay === null || !isCount(it.number))
87
+ continue;
88
+ // The title is read for the revert marker only and dropped here.
89
+ items.push({
90
+ number: it.number,
91
+ mergedDay,
92
+ updatedDay,
93
+ reverted: typeof it.title === "string" && /^revert\b/i.test(it.title),
94
+ });
95
+ }
96
+ return { items, next: list.length < INDEX_PAGE_SIZE ? null : String(page + 1), reads: 1 };
97
+ },
98
+ async readPr(repo, item, maxReads = Number.POSITIVE_INFINITY) {
99
+ if (!isValidRepoSlug(repo))
100
+ throw new Error("invalid repo");
101
+ if (!isCount(item.number) || maxReads < 2)
102
+ return { pr: null, reads: 0 };
103
+ const detail = (await guardedGet(`/repos/${repo}/pulls/${item.number}`, repo));
104
+ let reads = 1;
105
+ let ci;
106
+ const sha = detail.head?.sha;
107
+ if (typeof sha === "string" && /^[0-9a-f]{40}$/.test(sha)) {
108
+ const checks = (await guardedGet(`/repos/${repo}/commits/${sha}/check-runs?per_page=100`, repo));
109
+ reads += 1;
110
+ ci = rollupCi(Array.isArray(checks.check_runs) ? checks.check_runs : []);
111
+ }
112
+ if (!isCount(detail.additions) ||
113
+ !isCount(detail.deletions) ||
114
+ !isCount(detail.changed_files))
115
+ return { pr: null, reads };
116
+ const tools = detectAiTools(detail.body);
117
+ return {
118
+ pr: {
119
+ additions: detail.additions,
120
+ deletions: detail.deletions,
121
+ changed_files: detail.changed_files,
122
+ ci,
123
+ reverted: item.reverted,
124
+ tools,
125
+ },
126
+ reads,
127
+ };
128
+ },
129
+ };
130
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The org's Record totals for the scan preview: one read of
3
+ * `GET /v1/orgs/:org_id/record/totals?from=&to=` (window form, contract i2-totals-window v1.2).
4
+ * Read-only and local: the answer is held in memory to build the report and is never uploaded or
5
+ * stored (mnemom-api#4211: Mnemom stores no scan result).
6
+ *
7
+ * The window is always the last 30 days ending now (the contract's maximum). It is its own
8
+ * window, not the PR scan period, and the report labels it "last 30 days"; no other scan-window
9
+ * number is ever produced here.
10
+ *
11
+ * Outcomes are a closed set, and every one has fixed text. The thrown value, the response body
12
+ * and the credential never reach the terminal:
13
+ * ok totals read and shape-checked
14
+ * not_in_cohort 404: the org is not in the Record cohort (or the org is unknown). Not an error.
15
+ * unavailable anything else (429 cap, 5xx, timeout, network, off-shape body). Scan continues.
16
+ * auth 401/403 or a missing login: throws RecordAuthError (fail closed).
17
+ */
18
+ import { type RecordWindowTotals } from "../api.js";
19
+ export declare const RECORD_WINDOW_DAYS = 30;
20
+ export declare const RECORD_WINDOW_LABEL = "last 30 days";
21
+ export declare const RECORD_TIMEOUT_MS = 10000;
22
+ export declare const isOrgId: (v: unknown) => v is string;
23
+ export declare const RECORD_AUTH_MSG = "Record totals were refused for this org. Run `mnemom login` (or set MNEMOM_API_KEY) with an owner, admin or auditor of the org passed to --org, then retry.";
24
+ export declare class RecordAuthError extends Error {
25
+ constructor();
26
+ }
27
+ export interface RecordWindow {
28
+ from: string;
29
+ to: string;
30
+ }
31
+ export type RecordOutcome = {
32
+ status: "ok";
33
+ totals: RecordWindowTotals;
34
+ window: RecordWindow;
35
+ } | {
36
+ status: "not_in_cohort";
37
+ } | {
38
+ status: "unavailable";
39
+ };
40
+ export interface RecordDeps {
41
+ fetchTotals?: (orgId: string, from: string, to: string, signal: AbortSignal) => Promise<unknown>;
42
+ now?: () => number;
43
+ timeoutMs?: number;
44
+ }
45
+ export declare function recordWindow(nowMs: number): RecordWindow;
46
+ /** Shape-check the body: a malformed answer is "unavailable", never rendered. */
47
+ export declare function parseWindowTotals(v: unknown): RecordWindowTotals | null;
48
+ /** Never throws except RecordAuthError (401/403, fail closed). */
49
+ export declare function fetchRecordTotals(orgId: string, deps?: RecordDeps): Promise<RecordOutcome>;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The org's Record totals for the scan preview: one read of
3
+ * `GET /v1/orgs/:org_id/record/totals?from=&to=` (window form, contract i2-totals-window v1.2).
4
+ * Read-only and local: the answer is held in memory to build the report and is never uploaded or
5
+ * stored (mnemom-api#4211: Mnemom stores no scan result).
6
+ *
7
+ * The window is always the last 30 days ending now (the contract's maximum). It is its own
8
+ * window, not the PR scan period, and the report labels it "last 30 days"; no other scan-window
9
+ * number is ever produced here.
10
+ *
11
+ * Outcomes are a closed set, and every one has fixed text. The thrown value, the response body
12
+ * and the credential never reach the terminal:
13
+ * ok totals read and shape-checked
14
+ * not_in_cohort 404: the org is not in the Record cohort (or the org is unknown). Not an error.
15
+ * unavailable anything else (429 cap, 5xx, timeout, network, off-shape body). Scan continues.
16
+ * auth 401/403 or a missing login: throws RecordAuthError (fail closed).
17
+ */
18
+ import { getRecordWindowTotals, MnemomApiError } from "../api.js";
19
+ export const RECORD_WINDOW_DAYS = 30;
20
+ export const RECORD_WINDOW_LABEL = "last 30 days";
21
+ export const RECORD_TIMEOUT_MS = 10_000;
22
+ /** `to` may not be in the future; stay a minute behind so a fast local clock cannot trip it. */
23
+ const CLOCK_SKEW_MS = 60_000;
24
+ const DAY_MS = 24 * 60 * 60 * 1000;
25
+ const ORG_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
26
+ const MMU_RE = /^(0|[1-9][0-9]{0,18})$/;
27
+ export const isOrgId = (v) => typeof v === "string" && ORG_ID_RE.test(v);
28
+ export const RECORD_AUTH_MSG = "Record totals were refused for this org. Run `mnemom login` (or set MNEMOM_API_KEY) with an owner, admin or auditor of the org passed to --org, then retry.";
29
+ export class RecordAuthError extends Error {
30
+ constructor() {
31
+ super(RECORD_AUTH_MSG);
32
+ this.name = "RecordAuthError";
33
+ }
34
+ }
35
+ export function recordWindow(nowMs) {
36
+ const to = nowMs - CLOCK_SKEW_MS;
37
+ return {
38
+ from: new Date(to - RECORD_WINDOW_DAYS * DAY_MS).toISOString(),
39
+ to: new Date(to).toISOString(),
40
+ };
41
+ }
42
+ const isMmu = (v) => typeof v === "string" && MMU_RE.test(v);
43
+ /** Shape-check the body: a malformed answer is "unavailable", never rendered. */
44
+ export function parseWindowTotals(v) {
45
+ if (typeof v !== "object" || v === null)
46
+ return null;
47
+ const o = v;
48
+ if (!isMmu(o.model_cost_mmu))
49
+ return null;
50
+ if (o.mu_charged_mmu !== null && !isMmu(o.mu_charged_mmu))
51
+ return null;
52
+ if (typeof o.row_count !== "number" || !Number.isInteger(o.row_count) || o.row_count < 0)
53
+ return null;
54
+ if (o.as_of !== null && typeof o.as_of !== "string")
55
+ return null;
56
+ return {
57
+ model_cost_mmu: o.model_cost_mmu,
58
+ mu_charged_mmu: o.mu_charged_mmu,
59
+ row_count: o.row_count,
60
+ as_of: o.as_of,
61
+ };
62
+ }
63
+ /** Never throws except RecordAuthError (401/403, fail closed). */
64
+ export async function fetchRecordTotals(orgId, deps = {}) {
65
+ if (!isOrgId(orgId))
66
+ return { status: "unavailable" };
67
+ const window = recordWindow((deps.now ?? Date.now)());
68
+ const fetchTotals = deps.fetchTotals ?? getRecordWindowTotals;
69
+ const signal = AbortSignal.timeout(deps.timeoutMs ?? RECORD_TIMEOUT_MS);
70
+ try {
71
+ const body = await fetchTotals(orgId, window.from, window.to, signal);
72
+ const totals = parseWindowTotals(body);
73
+ return totals ? { status: "ok", totals, window } : { status: "unavailable" };
74
+ }
75
+ catch (e) {
76
+ if (e instanceof MnemomApiError) {
77
+ const s = e.effectiveStatus;
78
+ if (s === 401 || s === 403)
79
+ throw new RecordAuthError();
80
+ if (s === 404)
81
+ return { status: "not_in_cohort" };
82
+ }
83
+ return { status: "unavailable" };
84
+ }
85
+ }