@mnemom/mnemom 0.17.32 → 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.
- package/dist/commands/agent-fuel.d.ts +9 -0
- package/dist/commands/agent-fuel.js +36 -2
- package/dist/commands/agent-run.d.ts +50 -0
- package/dist/commands/agent-run.js +221 -0
- package/dist/commands/agent-setup.d.ts +8 -3
- package/dist/commands/agent-setup.js +32 -9
- package/dist/commands/agent-share-fixture.d.ts +3 -1
- package/dist/commands/agent-share-fixture.js +11 -7
- package/dist/commands/agent-share-render.d.ts +23 -0
- package/dist/commands/agent-share-render.js +72 -0
- package/dist/commands/agent-share.d.ts +15 -2
- package/dist/commands/agent-share.js +30 -9
- package/dist/commands/agent.d.ts +34 -4
- package/dist/commands/agent.js +116 -20
- package/dist/commands/protection.js +1 -1
- package/dist/commands/scan.d.ts +97 -0
- package/dist/commands/scan.js +238 -0
- package/dist/index.js +55 -0
- package/dist/lib/api.d.ts +13 -0
- package/dist/lib/api.js +25 -1
- package/dist/lib/auth.d.ts +6 -0
- package/dist/lib/auth.js +161 -14
- package/dist/lib/experimental.d.ts +1 -1
- package/dist/lib/experimental.js +13 -0
- package/dist/lib/oauth.d.ts +7 -0
- package/dist/lib/oauth.js +13 -0
- package/dist/lib/runner/bundle.d.ts +129 -0
- package/dist/lib/runner/bundle.js +319 -0
- package/dist/lib/runner/contract-header.d.ts +46 -0
- package/dist/lib/runner/contract-header.js +85 -0
- package/dist/lib/runner/executor-client.d.ts +71 -0
- package/dist/lib/runner/executor-client.js +97 -0
- package/dist/lib/runner/job.d.ts +70 -0
- package/dist/lib/runner/job.js +463 -0
- package/dist/lib/runner/loop.d.ts +45 -0
- package/dist/lib/runner/loop.js +104 -0
- package/dist/lib/runner/oidc.d.ts +38 -0
- package/dist/lib/runner/oidc.js +89 -0
- package/dist/lib/runner/options.d.ts +41 -0
- package/dist/lib/runner/options.js +47 -0
- package/dist/lib/runner/outcome.d.ts +47 -0
- package/dist/lib/runner/outcome.js +89 -0
- package/dist/lib/runner/phases.d.ts +49 -0
- package/dist/lib/runner/phases.js +99 -0
- package/dist/lib/runner/proxy.d.ts +45 -0
- package/dist/lib/runner/proxy.js +153 -0
- package/dist/lib/runner/settings.d.ts +31 -0
- package/dist/lib/runner/settings.js +44 -0
- package/dist/lib/scan/caps.d.ts +35 -0
- package/dist/lib/scan/caps.js +67 -0
- package/dist/lib/scan/credential.d.ts +37 -0
- package/dist/lib/scan/credential.js +104 -0
- package/dist/lib/scan/failure.d.ts +10 -0
- package/dist/lib/scan/failure.js +43 -0
- package/dist/lib/scan/pr-source.d.ts +57 -0
- package/dist/lib/scan/pr-source.js +130 -0
- package/dist/lib/scan/record.d.ts +49 -0
- package/dist/lib/scan/record.js +85 -0
- package/dist/lib/scan/report-email.d.ts +92 -0
- package/dist/lib/scan/report-email.js +136 -0
- package/dist/lib/scan/report.d.ts +13 -0
- package/dist/lib/scan/report.js +30 -0
- package/dist/lib/scan/scan.d.ts +64 -0
- package/dist/lib/scan/scan.js +182 -0
- package/dist/lib/scan/share.d.ts +38 -0
- package/dist/lib/scan/share.js +47 -0
- package/dist/lib/scan/spend/ai-tool-detect.d.ts +6 -0
- package/dist/lib/scan/spend/ai-tool-detect.js +18 -0
- package/dist/lib/scan/spend/anthropic-usage.d.ts +57 -0
- package/dist/lib/scan/spend/anthropic-usage.js +244 -0
- package/dist/lib/scan/spend/cost-corpus.v1.d.ts +56 -0
- package/dist/lib/scan/spend/cost-corpus.v1.js +64 -0
- package/dist/lib/scan/spend/prices.d.ts +33 -0
- package/dist/lib/scan/spend/prices.js +57 -0
- package/dist/lib/scan/spend/spend-estimate.d.ts +30 -0
- package/dist/lib/scan/spend/spend-estimate.js +70 -0
- package/dist/lib/scan/spend/spend-tiers.d.ts +25 -0
- package/dist/lib/scan/spend/spend-tiers.js +42 -0
- package/dist/lib/scan/spend/token-rate.d.ts +30 -0
- package/dist/lib/scan/spend/token-rate.js +54 -0
- package/dist/lib/session-key.d.ts +41 -0
- package/dist/lib/session-key.js +124 -0
- package/dist/lib/statusline-feed.js +10 -11
- package/dist/lib/statusline.d.ts +6 -0
- package/dist/lib/statusline.js +19 -9
- package/dist/rc-key-401.mjs +75 -0
- package/dist/rc-proxy.mjs +290 -30
- package/package.json +2 -1
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin runner settings (Doors W-L13, W4.4): what the runner does on a Mnemom error, and how a job
|
|
3
|
+
* lands once its PR is open.
|
|
4
|
+
*
|
|
5
|
+
* Fail mode (decision 20, OD-2): served-phase jobs fail CLOSED by default. A Mnemom error (claim,
|
|
6
|
+
* bundle fetch, bundle verification, the job read, the gateway) stops the job and leaves it for
|
|
7
|
+
* the lease to lapse back to ready; nothing runs unverified or ungoverned. `open` lets a job that
|
|
8
|
+
* already holds a verified bundle carry on through a transient error on a later call. It never
|
|
9
|
+
* lets an unverified bundle run: verification failures stop the job in every mode. An unknown
|
|
10
|
+
* value reads as the default, closed, with a warning, so a typo can never weaken the runner.
|
|
11
|
+
*
|
|
12
|
+
* Merge strategy (W4.4, T15): the bundle may name `merge_on_green` (our fleet) or `external`
|
|
13
|
+
* (a person merges). A bundle that names none resolves to `external`: the job opens the PR and
|
|
14
|
+
* stops.
|
|
15
|
+
*/
|
|
16
|
+
import type { MergeStrategy, ServedPhaseBundle } from "./bundle.js";
|
|
17
|
+
export type RunnerFailMode = "closed" | "open";
|
|
18
|
+
export declare const RUNNER_FAIL_MODE_ENV = "MNEMOM_RUNNER_FAIL_MODE";
|
|
19
|
+
export declare const DEFAULT_RUNNER_FAIL_MODE: RunnerFailMode;
|
|
20
|
+
export interface ResolvedFailMode {
|
|
21
|
+
mode: RunnerFailMode;
|
|
22
|
+
/** Set when a value was given but not recognised (the default applied). */
|
|
23
|
+
warning?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The fail mode for one runner. Precedence: the `--fail-mode` flag, then MNEMOM_RUNNER_FAIL_MODE,
|
|
27
|
+
* then closed. Matching is case-insensitive and trims whitespace; an empty value counts as unset.
|
|
28
|
+
*/
|
|
29
|
+
export declare function resolveFailMode(flag: string | undefined, env: string | undefined): ResolvedFailMode;
|
|
30
|
+
/** The bundle's merge strategy, `external` when it names none. */
|
|
31
|
+
export declare function resolveMergeStrategy(bundle: Pick<ServedPhaseBundle, "merge_strategy">): MergeStrategy;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin runner settings (Doors W-L13, W4.4): what the runner does on a Mnemom error, and how a job
|
|
3
|
+
* lands once its PR is open.
|
|
4
|
+
*
|
|
5
|
+
* Fail mode (decision 20, OD-2): served-phase jobs fail CLOSED by default. A Mnemom error (claim,
|
|
6
|
+
* bundle fetch, bundle verification, the job read, the gateway) stops the job and leaves it for
|
|
7
|
+
* the lease to lapse back to ready; nothing runs unverified or ungoverned. `open` lets a job that
|
|
8
|
+
* already holds a verified bundle carry on through a transient error on a later call. It never
|
|
9
|
+
* lets an unverified bundle run: verification failures stop the job in every mode. An unknown
|
|
10
|
+
* value reads as the default, closed, with a warning, so a typo can never weaken the runner.
|
|
11
|
+
*
|
|
12
|
+
* Merge strategy (W4.4, T15): the bundle may name `merge_on_green` (our fleet) or `external`
|
|
13
|
+
* (a person merges). A bundle that names none resolves to `external`: the job opens the PR and
|
|
14
|
+
* stops.
|
|
15
|
+
*/
|
|
16
|
+
export const RUNNER_FAIL_MODE_ENV = "MNEMOM_RUNNER_FAIL_MODE";
|
|
17
|
+
export const DEFAULT_RUNNER_FAIL_MODE = "closed";
|
|
18
|
+
/**
|
|
19
|
+
* The fail mode for one runner. Precedence: the `--fail-mode` flag, then MNEMOM_RUNNER_FAIL_MODE,
|
|
20
|
+
* then closed. Matching is case-insensitive and trims whitespace; an empty value counts as unset.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveFailMode(flag, env) {
|
|
23
|
+
for (const [source, raw] of [
|
|
24
|
+
["--fail-mode", flag],
|
|
25
|
+
[RUNNER_FAIL_MODE_ENV, env],
|
|
26
|
+
]) {
|
|
27
|
+
if (raw === undefined)
|
|
28
|
+
continue;
|
|
29
|
+
const v = raw.trim().toLowerCase();
|
|
30
|
+
if (v === "")
|
|
31
|
+
continue;
|
|
32
|
+
if (v === "closed" || v === "open")
|
|
33
|
+
return { mode: v };
|
|
34
|
+
return {
|
|
35
|
+
mode: DEFAULT_RUNNER_FAIL_MODE,
|
|
36
|
+
warning: `${source}=${JSON.stringify(raw)} is not closed or open; failing closed`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return { mode: DEFAULT_RUNNER_FAIL_MODE };
|
|
40
|
+
}
|
|
41
|
+
/** The bundle's merge strategy, `external` when it names none. */
|
|
42
|
+
export function resolveMergeStrategy(bundle) {
|
|
43
|
+
return bundle.merge_strategy ?? "external";
|
|
44
|
+
}
|
|
@@ -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>;
|