@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,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
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { ScanResult } from "./scan.js";
|
|
2
|
+
import type { ShareLevel } from "./share.js";
|
|
3
|
+
export declare const REPORT_SCHEMA_VERSION = "org-scan-report/1";
|
|
4
|
+
export declare const ESTIMATE_NAME = "automatable_work_share";
|
|
5
|
+
export declare const METHOD_URL = "https://www.mnemom.ai/method";
|
|
6
|
+
export interface EmailReport {
|
|
7
|
+
schema_version: typeof REPORT_SCHEMA_VERSION;
|
|
8
|
+
org_id: string;
|
|
9
|
+
scan_id: string;
|
|
10
|
+
period: {
|
|
11
|
+
start: string;
|
|
12
|
+
end: string;
|
|
13
|
+
};
|
|
14
|
+
generated_at: string;
|
|
15
|
+
measured: {
|
|
16
|
+
teams: [];
|
|
17
|
+
summary: {
|
|
18
|
+
pr_count: number;
|
|
19
|
+
revert_count: number;
|
|
20
|
+
automatable_count: number;
|
|
21
|
+
examined_count: number;
|
|
22
|
+
tokens_total: 0;
|
|
23
|
+
};
|
|
24
|
+
spend: {
|
|
25
|
+
source: "pr_estimate";
|
|
26
|
+
figure?: {
|
|
27
|
+
low_cents: number;
|
|
28
|
+
high_cents: number;
|
|
29
|
+
};
|
|
30
|
+
ai_prs_by_tool?: Array<{
|
|
31
|
+
tool: string;
|
|
32
|
+
count: number;
|
|
33
|
+
}>;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
estimate: {
|
|
37
|
+
name: string;
|
|
38
|
+
value: number;
|
|
39
|
+
unit: string;
|
|
40
|
+
method_url: string;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export interface ReportEmailBody {
|
|
44
|
+
report: EmailReport;
|
|
45
|
+
share_level: ShareLevel;
|
|
46
|
+
coverage: {
|
|
47
|
+
complete: boolean;
|
|
48
|
+
};
|
|
49
|
+
cto_email?: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Maps the local scan onto the route's closed report shape. Team totals stay empty (they come only from
|
|
53
|
+
* Anthropic usage, which the local scan does not carry). The estimate is the share of examined merged
|
|
54
|
+
* PRs that finished with every CI check passing. `figure` (tier 3 range) is included only when the CLI
|
|
55
|
+
* itself would let that line leave the machine (`sendSpendRange`).
|
|
56
|
+
*/
|
|
57
|
+
export declare function buildEmailReport(result: ScanResult, o: {
|
|
58
|
+
orgId: string;
|
|
59
|
+
period: {
|
|
60
|
+
start: string;
|
|
61
|
+
end: string;
|
|
62
|
+
};
|
|
63
|
+
now: () => number;
|
|
64
|
+
sendSpendRange: boolean;
|
|
65
|
+
scanId?: string;
|
|
66
|
+
}): EmailReport;
|
|
67
|
+
export declare function buildEmailBody(report: EmailReport, level: ShareLevel, complete: boolean, ctoEmail?: string): ReportEmailBody;
|
|
68
|
+
/** Closed set of failure names; each has fixed text. */
|
|
69
|
+
export type EmailFailure = "presend_check_failed" | "email_not_sent" | "not_signed_in" | "not_a_member" | "report_rejected" | "no_recipients" | "network_error" | "unexpected_response";
|
|
70
|
+
export declare const failureMessage: (f: EmailFailure) => string;
|
|
71
|
+
export declare const DRY_RUN_MSG = "Report email is in dry-run on this environment; nothing was sent.";
|
|
72
|
+
export declare const UNAVAILABLE_MSG = "Report email is not available for this org yet; nothing was sent.";
|
|
73
|
+
export declare class ReportEmailError extends Error {
|
|
74
|
+
readonly reason: EmailFailure;
|
|
75
|
+
constructor(reason: EmailFailure);
|
|
76
|
+
}
|
|
77
|
+
export type EmailOutcome = {
|
|
78
|
+
status: "sent";
|
|
79
|
+
recipients: number;
|
|
80
|
+
} | {
|
|
81
|
+
status: "dry_run";
|
|
82
|
+
} | {
|
|
83
|
+
status: "unavailable";
|
|
84
|
+
} | {
|
|
85
|
+
status: "failed";
|
|
86
|
+
reason: EmailFailure;
|
|
87
|
+
};
|
|
88
|
+
export interface ReportEmailDeps {
|
|
89
|
+
post?: (endpoint: string, body: ReportEmailBody) => Promise<unknown>;
|
|
90
|
+
}
|
|
91
|
+
/** Never throws. The thrown value, the response body and the credential are never surfaced. */
|
|
92
|
+
export declare function sendReportEmail(orgId: string, body: ReportEmailBody, deps?: ReportEmailDeps): Promise<EmailOutcome>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The report email: after every successful scan the person who ran it is emailed the report, with no
|
|
3
|
+
* opt-out and at every share level. One call to `POST /v1/orgs/{org_id}/scan/report-email`
|
|
4
|
+
* (mnemom-api#4270). The server resolves the runner's address from the signed-in identity, so the
|
|
5
|
+
* request carries no runner address; `cto_email` is sent only when the user passed --cto-email.
|
|
6
|
+
*
|
|
7
|
+
* The body is exactly the route's contract and nothing else: `report` (org-scan-report/1: team totals
|
|
8
|
+
* and counts only), `share_level`, `coverage.complete` and the optional `cto_email`. No Record totals,
|
|
9
|
+
* no repo names, no author, title, branch or body text, no credential.
|
|
10
|
+
*
|
|
11
|
+
* Every failure has a fixed name and fixed text. The server's body, the thrown value and the
|
|
12
|
+
* credential never reach the terminal.
|
|
13
|
+
*/
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { MnemomApiError, postApi } from "../api.js";
|
|
16
|
+
export const REPORT_SCHEMA_VERSION = "org-scan-report/1";
|
|
17
|
+
export const ESTIMATE_NAME = "automatable_work_share";
|
|
18
|
+
export const METHOD_URL = "https://www.mnemom.ai/method";
|
|
19
|
+
const cents = (usd) => Math.max(0, Math.round(usd * 100));
|
|
20
|
+
/**
|
|
21
|
+
* Maps the local scan onto the route's closed report shape. Team totals stay empty (they come only from
|
|
22
|
+
* Anthropic usage, which the local scan does not carry). The estimate is the share of examined merged
|
|
23
|
+
* PRs that finished with every CI check passing. `figure` (tier 3 range) is included only when the CLI
|
|
24
|
+
* itself would let that line leave the machine (`sendSpendRange`).
|
|
25
|
+
*/
|
|
26
|
+
export function buildEmailReport(result, o) {
|
|
27
|
+
const { totals, coverage } = result;
|
|
28
|
+
const value = totals.examined > 0 ? Math.round((totals.ciGreen / totals.examined) * 1000) / 10 : 0;
|
|
29
|
+
const scope = coverage.complete
|
|
30
|
+
? "every merged pull request in the period"
|
|
31
|
+
: "a capped sample (not every merged pull request in the period)";
|
|
32
|
+
const unit = `percent of merged pull requests examined (${totals.examined} of ${totals.prs}, ${scope}) ` +
|
|
33
|
+
`that finished with all CI checks passing; an estimate from pull request metadata, not a measured share`;
|
|
34
|
+
const byTool = result.spend.kind === "range"
|
|
35
|
+
? Object.entries(result.spend.byTool)
|
|
36
|
+
.filter(([, n]) => typeof n === "number" && n > 0)
|
|
37
|
+
.map(([tool, count]) => ({ tool, count: count }))
|
|
38
|
+
: [];
|
|
39
|
+
return {
|
|
40
|
+
schema_version: REPORT_SCHEMA_VERSION,
|
|
41
|
+
org_id: o.orgId,
|
|
42
|
+
scan_id: o.scanId ?? randomUUID(),
|
|
43
|
+
period: o.period,
|
|
44
|
+
generated_at: new Date(o.now()).toISOString(),
|
|
45
|
+
measured: {
|
|
46
|
+
teams: [],
|
|
47
|
+
summary: {
|
|
48
|
+
pr_count: totals.prs,
|
|
49
|
+
revert_count: totals.reverts,
|
|
50
|
+
automatable_count: totals.ciGreen,
|
|
51
|
+
examined_count: totals.examined,
|
|
52
|
+
tokens_total: 0,
|
|
53
|
+
},
|
|
54
|
+
spend: {
|
|
55
|
+
source: "pr_estimate",
|
|
56
|
+
...(o.sendSpendRange && result.spend.kind === "range"
|
|
57
|
+
? {
|
|
58
|
+
figure: {
|
|
59
|
+
low_cents: cents(result.spend.lowUsd),
|
|
60
|
+
high_cents: cents(result.spend.highUsd),
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
: {}),
|
|
64
|
+
...(o.sendSpendRange && byTool.length > 0 ? { ai_prs_by_tool: byTool } : {}),
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
estimate: { name: ESTIMATE_NAME, value, unit, method_url: METHOD_URL },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function buildEmailBody(report, level, complete, ctoEmail) {
|
|
71
|
+
return {
|
|
72
|
+
report,
|
|
73
|
+
share_level: level,
|
|
74
|
+
coverage: { complete },
|
|
75
|
+
...(ctoEmail !== undefined ? { cto_email: ctoEmail } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const FAILURE_TEXT = {
|
|
79
|
+
presend_check_failed: "the report failed Mnemom's pre-send checks, so no email was sent.",
|
|
80
|
+
email_not_sent: "Mnemom could not send the email.",
|
|
81
|
+
not_signed_in: "you are not signed in. Run `mnemom login` and re-run the scan.",
|
|
82
|
+
not_a_member: "your account is not a member of the org passed to --org.",
|
|
83
|
+
report_rejected: "Mnemom rejected the report as malformed.",
|
|
84
|
+
no_recipients: "the server accepted the report but reported zero recipients.",
|
|
85
|
+
network_error: "Mnemom could not be reached.",
|
|
86
|
+
unexpected_response: "Mnemom answered in an unexpected way.",
|
|
87
|
+
};
|
|
88
|
+
export const failureMessage = (f) => `Your report was NOT emailed: ${f}. ${FAILURE_TEXT[f]}`;
|
|
89
|
+
export const DRY_RUN_MSG = "Report email is in dry-run on this environment; nothing was sent.";
|
|
90
|
+
export const UNAVAILABLE_MSG = "Report email is not available for this org yet; nothing was sent.";
|
|
91
|
+
export class ReportEmailError extends Error {
|
|
92
|
+
reason;
|
|
93
|
+
constructor(reason) {
|
|
94
|
+
super(failureMessage(reason));
|
|
95
|
+
this.reason = reason;
|
|
96
|
+
this.name = "ReportEmailError";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const defaultPost = (endpoint, body) => postApi(endpoint, body);
|
|
100
|
+
function classifyStatus(status) {
|
|
101
|
+
if (status === 401)
|
|
102
|
+
return "not_signed_in";
|
|
103
|
+
if (status === 403)
|
|
104
|
+
return "not_a_member";
|
|
105
|
+
if (status === 422)
|
|
106
|
+
return "presend_check_failed";
|
|
107
|
+
if (status === 502)
|
|
108
|
+
return "email_not_sent";
|
|
109
|
+
if (status === 400 || status === 413)
|
|
110
|
+
return "report_rejected";
|
|
111
|
+
return "unexpected_response";
|
|
112
|
+
}
|
|
113
|
+
/** Never throws. The thrown value, the response body and the credential are never surfaced. */
|
|
114
|
+
export async function sendReportEmail(orgId, body, deps = {}) {
|
|
115
|
+
const post = deps.post ?? defaultPost;
|
|
116
|
+
let res;
|
|
117
|
+
try {
|
|
118
|
+
res = await post(`/v1/orgs/${encodeURIComponent(orgId)}/scan/report-email`, body);
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
if (e instanceof MnemomApiError && e.effectiveStatus === 404)
|
|
122
|
+
return { status: "unavailable" };
|
|
123
|
+
if (e instanceof MnemomApiError)
|
|
124
|
+
return { status: "failed", reason: classifyStatus(e.effectiveStatus) };
|
|
125
|
+
return { status: "failed", reason: "network_error" };
|
|
126
|
+
}
|
|
127
|
+
const o = typeof res === "object" && res !== null ? res : null;
|
|
128
|
+
const recipients = o?.recipients;
|
|
129
|
+
if (o === null || (o.status !== "sent" && o.status !== "dry_run"))
|
|
130
|
+
return { status: "failed", reason: "unexpected_response" };
|
|
131
|
+
if (typeof recipients !== "number" || !Number.isInteger(recipients) || recipients < 0)
|
|
132
|
+
return { status: "failed", reason: "unexpected_response" };
|
|
133
|
+
if (recipients === 0)
|
|
134
|
+
return { status: "failed", reason: "no_recipients" };
|
|
135
|
+
return o.status === "dry_run" ? { status: "dry_run" } : { status: "sent", recipients };
|
|
136
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders the scan preview report from values already in memory: the local PR scan plus the org's
|
|
3
|
+
* Record totals. Pure (returns lines, prints nothing) and never persists anything. Totals are
|
|
4
|
+
* labelled "last 30 days"; no other window number is invented. Money is integer millimu
|
|
5
|
+
* (1 MU = 1000 mmu), formatted with BigInt so values above 2^53 stay exact.
|
|
6
|
+
*/
|
|
7
|
+
import { type RecordOutcome } from "./record.js";
|
|
8
|
+
export declare const RECORD_NOT_AVAILABLE_NOTE = "Records not available for this org.";
|
|
9
|
+
export declare const RECORD_UNAVAILABLE_NOTE = "Records could not be read right now; continuing without them.";
|
|
10
|
+
export declare const RECORD_NO_ORG_NOTE = "Records skipped: pass --org <org_id> to include the org's Record totals.";
|
|
11
|
+
/** "12.5 MU" from "12500". Caller guarantees a canonical mmu string. */
|
|
12
|
+
export declare function formatMu(mmu: string): string;
|
|
13
|
+
export declare function renderRecordSection(outcome: RecordOutcome): string[];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders the scan preview report from values already in memory: the local PR scan plus the org's
|
|
3
|
+
* Record totals. Pure (returns lines, prints nothing) and never persists anything. Totals are
|
|
4
|
+
* labelled "last 30 days"; no other window number is invented. Money is integer millimu
|
|
5
|
+
* (1 MU = 1000 mmu), formatted with BigInt so values above 2^53 stay exact.
|
|
6
|
+
*/
|
|
7
|
+
import { RECORD_WINDOW_LABEL } from "./record.js";
|
|
8
|
+
export const RECORD_NOT_AVAILABLE_NOTE = "Records not available for this org.";
|
|
9
|
+
export const RECORD_UNAVAILABLE_NOTE = "Records could not be read right now; continuing without them.";
|
|
10
|
+
export const RECORD_NO_ORG_NOTE = "Records skipped: pass --org <org_id> to include the org's Record totals.";
|
|
11
|
+
/** "12.5 MU" from "12500". Caller guarantees a canonical mmu string. */
|
|
12
|
+
export function formatMu(mmu) {
|
|
13
|
+
const n = BigInt(mmu);
|
|
14
|
+
const whole = (n / 1000n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
15
|
+
const frac = (n % 1000n).toString().padStart(3, "0").replace(/0+$/, "");
|
|
16
|
+
return `${whole}${frac ? `.${frac}` : ""} MU`;
|
|
17
|
+
}
|
|
18
|
+
export function renderRecordSection(outcome) {
|
|
19
|
+
if (outcome.status === "not_in_cohort")
|
|
20
|
+
return [RECORD_NOT_AVAILABLE_NOTE];
|
|
21
|
+
if (outcome.status === "unavailable")
|
|
22
|
+
return [RECORD_UNAVAILABLE_NOTE];
|
|
23
|
+
const t = outcome.totals;
|
|
24
|
+
return [
|
|
25
|
+
`Org Record (${RECORD_WINDOW_LABEL}, read from Mnemom; not stored):`,
|
|
26
|
+
` Model cost: ${formatMu(t.model_cost_mmu)}`,
|
|
27
|
+
` Charged: ${t.mu_charged_mmu === null ? "not charged" : formatMu(t.mu_charged_mmu)}`,
|
|
28
|
+
` Requests recorded: ${t.row_count}`,
|
|
29
|
+
];
|
|
30
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local capped scan core. Port of the window path of runCappedScan (mnemom-api capped-scan.ts, PR #4208)
|
|
3
|
+
* plus the spend estimate (#4222). Everything outside is injected (credential, PR source, clock), so the
|
|
4
|
+
* core touches no network or disk, and the report exists only in memory.
|
|
5
|
+
*/
|
|
6
|
+
import { type GitHubCredential } from "./credential.js";
|
|
7
|
+
import { type ScanCaps, type StopReason } from "./caps.js";
|
|
8
|
+
import { type FailureReason } from "./failure.js";
|
|
9
|
+
import { type PrSource } from "./pr-source.js";
|
|
10
|
+
import { type SpendEstimate } from "./spend/spend-estimate.js";
|
|
11
|
+
export interface ScanInput {
|
|
12
|
+
repos: string[];
|
|
13
|
+
/** Inclusive dates, YYYY-MM-DD. */
|
|
14
|
+
period: {
|
|
15
|
+
start: string;
|
|
16
|
+
end: string;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export interface ScanDeps {
|
|
20
|
+
credential: GitHubCredential;
|
|
21
|
+
prs?: PrSource;
|
|
22
|
+
fetchImpl?: typeof fetch;
|
|
23
|
+
now?: () => number;
|
|
24
|
+
caps?: Partial<ScanCaps>;
|
|
25
|
+
}
|
|
26
|
+
export interface Coverage {
|
|
27
|
+
complete: boolean;
|
|
28
|
+
stoppedBy: StopReason | null;
|
|
29
|
+
prsExamined: number;
|
|
30
|
+
prsSkippedLarge: number;
|
|
31
|
+
prsUnreadable: number;
|
|
32
|
+
reposRead: number;
|
|
33
|
+
reposTotal: number;
|
|
34
|
+
reads: number;
|
|
35
|
+
windowStart: string;
|
|
36
|
+
windowEnd: string;
|
|
37
|
+
/** Set only when the window held more merged PRs than the quota and an even sample was taken. */
|
|
38
|
+
windowSampledFrom?: number;
|
|
39
|
+
}
|
|
40
|
+
export interface RepoTotals {
|
|
41
|
+
repo: string;
|
|
42
|
+
prs: number;
|
|
43
|
+
reverts: number;
|
|
44
|
+
ciGreen: number;
|
|
45
|
+
}
|
|
46
|
+
export interface ScanResult {
|
|
47
|
+
coverage: Coverage;
|
|
48
|
+
totals: {
|
|
49
|
+
prs: number;
|
|
50
|
+
reverts: number;
|
|
51
|
+
ciGreen: number;
|
|
52
|
+
examined: number;
|
|
53
|
+
};
|
|
54
|
+
byRepo: RepoTotals[];
|
|
55
|
+
spend: SpendEstimate;
|
|
56
|
+
}
|
|
57
|
+
export type ScanOutcome = {
|
|
58
|
+
status: "done";
|
|
59
|
+
result: ScanResult;
|
|
60
|
+
} | {
|
|
61
|
+
status: "withheld";
|
|
62
|
+
reason: FailureReason;
|
|
63
|
+
};
|
|
64
|
+
export declare function runLocalScan(deps: ScanDeps, input: ScanInput): Promise<ScanOutcome>;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local capped scan core. Port of the window path of runCappedScan (mnemom-api capped-scan.ts, PR #4208)
|
|
3
|
+
* plus the spend estimate (#4222). Everything outside is injected (credential, PR source, clock), so the
|
|
4
|
+
* core touches no network or disk, and the report exists only in memory.
|
|
5
|
+
*/
|
|
6
|
+
import { assertReadOnlyCredential, isValidRepoSlug } from "./credential.js";
|
|
7
|
+
import { Budget, COVERAGE_MONTHS, evenSample, monthsBefore, sanitiseCaps, } from "./caps.js";
|
|
8
|
+
import { classifyFailure } from "./failure.js";
|
|
9
|
+
import { createGithubPrSource } from "./pr-source.js";
|
|
10
|
+
import { estimateSpend } from "./spend/spend-estimate.js";
|
|
11
|
+
const validDay = (d) => typeof d === "string" &&
|
|
12
|
+
/^\d{4}-\d{2}-\d{2}$/.test(d) &&
|
|
13
|
+
!Number.isNaN(Date.parse(`${d}T00:00:00Z`));
|
|
14
|
+
export async function runLocalScan(deps, input) {
|
|
15
|
+
if (!validDay(input.period.start) ||
|
|
16
|
+
!validDay(input.period.end) ||
|
|
17
|
+
input.repos.length === 0 ||
|
|
18
|
+
!input.repos.every(isValidRepoSlug)) {
|
|
19
|
+
return { status: "withheld", reason: "invalid_input" };
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
await assertReadOnlyCredential(deps.credential);
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
return { status: "withheld", reason: classifyFailure(e) };
|
|
26
|
+
}
|
|
27
|
+
const prs = deps.prs ?? createGithubPrSource({ credential: deps.credential, fetchImpl: deps.fetchImpl });
|
|
28
|
+
const now = deps.now ?? Date.now;
|
|
29
|
+
const caps = sanitiseCaps(deps.caps ?? {});
|
|
30
|
+
const budget = new Budget(caps, now, now());
|
|
31
|
+
let stoppedBy = null;
|
|
32
|
+
const stop = (r) => {
|
|
33
|
+
if (r && !stoppedBy)
|
|
34
|
+
stoppedBy = r;
|
|
35
|
+
return stoppedBy !== null;
|
|
36
|
+
};
|
|
37
|
+
const periodStart = input.period.start;
|
|
38
|
+
const windowStart = periodStart < monthsBefore(input.period.end, COVERAGE_MONTHS)
|
|
39
|
+
? periodStart
|
|
40
|
+
: monthsBefore(input.period.end, COVERAGE_MONTHS);
|
|
41
|
+
const endDay = input.period.end;
|
|
42
|
+
const pool = [];
|
|
43
|
+
let reposRead = 0;
|
|
44
|
+
let listingCut = false;
|
|
45
|
+
try {
|
|
46
|
+
for (const repo of input.repos) {
|
|
47
|
+
if (stoppedBy !== null || stop(budget.block()))
|
|
48
|
+
break;
|
|
49
|
+
reposRead += 1;
|
|
50
|
+
let cursor = null;
|
|
51
|
+
const days = [];
|
|
52
|
+
let oldestUpdated = "9999-99-99";
|
|
53
|
+
do {
|
|
54
|
+
if (stop(budget.block()))
|
|
55
|
+
break;
|
|
56
|
+
// Listing may use at most half the read budget, so it never starves the reads of the PRs it found.
|
|
57
|
+
if (budget.reads * 2 > caps.maxReads) {
|
|
58
|
+
listingCut = true;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
budget.charge(1);
|
|
62
|
+
const page = await prs.indexMerged(repo, cursor, budget.room() + 1);
|
|
63
|
+
budget.charge((Number.isFinite(page.reads) && page.reads >= 1 ? page.reads : 1) - 1);
|
|
64
|
+
const overrun = budget.overrun();
|
|
65
|
+
if (overrun) {
|
|
66
|
+
stop(overrun);
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
for (const item of page.items) {
|
|
70
|
+
pool.push({ repo, item });
|
|
71
|
+
days.push(item.mergedDay);
|
|
72
|
+
if (item.updatedDay < oldestUpdated)
|
|
73
|
+
oldestUpdated = item.updatedDay;
|
|
74
|
+
}
|
|
75
|
+
cursor = page.next;
|
|
76
|
+
// Newest-activity first: once a seen PR was last touched before D, no later page holds a PR
|
|
77
|
+
// merged on or after D. Stop when D is both the window start and the merge day of this repo's
|
|
78
|
+
// maxPrs-th newest merged PR.
|
|
79
|
+
const kth = days.length >= caps.maxPrs ? [...days].sort().reverse()[caps.maxPrs - 1] : null;
|
|
80
|
+
if (kth !== null && oldestUpdated < (kth < windowStart ? kth : windowStart))
|
|
81
|
+
break;
|
|
82
|
+
} while (cursor !== null && stoppedBy === null);
|
|
83
|
+
if (listingCut) {
|
|
84
|
+
if (cursor === null && days.length === 0)
|
|
85
|
+
reposRead -= 1;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Newest first, ties by repo then number: the same pool always gives the same sample.
|
|
90
|
+
for (let i = pool.length - 1; i >= 0; i--)
|
|
91
|
+
if (pool[i].item.mergedDay > endDay)
|
|
92
|
+
pool.splice(i, 1);
|
|
93
|
+
pool.sort((a, b) => (a.item.mergedDay === b.item.mergedDay
|
|
94
|
+
? 0
|
|
95
|
+
: a.item.mergedDay < b.item.mergedDay
|
|
96
|
+
? 1
|
|
97
|
+
: -1) ||
|
|
98
|
+
(a.repo < b.repo ? -1 : a.repo > b.repo ? 1 : 0) ||
|
|
99
|
+
b.item.number - a.item.number);
|
|
100
|
+
const inWindow = pool.filter((p) => p.item.mergedDay >= windowStart);
|
|
101
|
+
const sampled = inWindow.length > caps.maxPrs;
|
|
102
|
+
const chosen = sampled ? evenSample(inWindow, caps.maxPrs) : pool.slice(0, caps.maxPrs);
|
|
103
|
+
const byRepo = new Map();
|
|
104
|
+
const bucket = (repo) => {
|
|
105
|
+
let b = byRepo.get(repo);
|
|
106
|
+
if (!b)
|
|
107
|
+
byRepo.set(repo, (b = { repo, prs: 0, reverts: 0, ciGreen: 0 }));
|
|
108
|
+
return b;
|
|
109
|
+
};
|
|
110
|
+
const totals = { prs: 0, reverts: 0, ciGreen: 0, examined: 0 };
|
|
111
|
+
const spendInputs = [];
|
|
112
|
+
let skippedLarge = 0;
|
|
113
|
+
let unreadable = 0;
|
|
114
|
+
for (const { repo, item } of chosen) {
|
|
115
|
+
// A revert PR is never a candidate: counted from the listing, never read.
|
|
116
|
+
if (item.reverted) {
|
|
117
|
+
totals.prs += 1;
|
|
118
|
+
totals.reverts += 1;
|
|
119
|
+
bucket(repo).prs += 1;
|
|
120
|
+
bucket(repo).reverts += 1;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (stop(budget.block(2)))
|
|
124
|
+
break;
|
|
125
|
+
budget.charge(1);
|
|
126
|
+
const res = await prs.readPr(repo, item, budget.room() + 1);
|
|
127
|
+
budget.charge((Number.isFinite(res.reads) && res.reads >= 1 ? res.reads : 1) - 1);
|
|
128
|
+
const overrun = budget.overrun();
|
|
129
|
+
if (overrun) {
|
|
130
|
+
stop(overrun);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
const rec = res.pr;
|
|
134
|
+
if (!rec) {
|
|
135
|
+
unreadable += 1;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (rec.additions + rec.deletions > caps.maxPrSizeLines) {
|
|
139
|
+
skippedLarge += 1;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
totals.prs += 1;
|
|
143
|
+
totals.examined += 1;
|
|
144
|
+
bucket(repo).prs += 1;
|
|
145
|
+
if (rec.ci === "success") {
|
|
146
|
+
totals.ciGreen += 1;
|
|
147
|
+
bucket(repo).ciGreen += 1;
|
|
148
|
+
}
|
|
149
|
+
spendInputs.push({
|
|
150
|
+
additions: rec.additions,
|
|
151
|
+
deletions: rec.deletions,
|
|
152
|
+
markText: "",
|
|
153
|
+
tools: rec.tools,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const coverage = {
|
|
157
|
+
complete: stoppedBy === null &&
|
|
158
|
+
!listingCut &&
|
|
159
|
+
unreadable === 0 &&
|
|
160
|
+
reposRead === input.repos.length &&
|
|
161
|
+
!sampled,
|
|
162
|
+
stoppedBy: stoppedBy ?? (listingCut ? "read-cap" : null),
|
|
163
|
+
prsExamined: totals.examined,
|
|
164
|
+
prsSkippedLarge: skippedLarge,
|
|
165
|
+
prsUnreadable: unreadable,
|
|
166
|
+
reposRead,
|
|
167
|
+
reposTotal: input.repos.length,
|
|
168
|
+
reads: budget.reads,
|
|
169
|
+
windowStart,
|
|
170
|
+
windowEnd: endDay,
|
|
171
|
+
...(sampled ? { windowSampledFrom: inWindow.length } : {}),
|
|
172
|
+
};
|
|
173
|
+
return {
|
|
174
|
+
status: "done",
|
|
175
|
+
result: { coverage, totals, byRepo: [...byRepo.values()], spend: estimateSpend(spendInputs) },
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
// Reads only; the raw error is dropped and a fixed reason returned.
|
|
180
|
+
return { status: "withheld", reason: classifyFailure(e) };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ScanResult } from "./scan.js";
|
|
2
|
+
export type ShareLevel = "summary" | "full" | "none";
|
|
3
|
+
export declare const SHARE_OPTIONS: ReadonlyArray<{
|
|
4
|
+
level: ShareLevel;
|
|
5
|
+
label: string;
|
|
6
|
+
}>;
|
|
7
|
+
/** Asks the user. Anything but a recognised answer (cancel, EOF, noise) is "none": fail closed. */
|
|
8
|
+
export declare function promptShareLevel(): Promise<ShareLevel>;
|
|
9
|
+
export declare function parseShareLevel(v: string | undefined): ShareLevel | null;
|
|
10
|
+
/** Who receives the report. Sent only with a non-"none" share level. */
|
|
11
|
+
export interface ReportRecipients {
|
|
12
|
+
runner_email: string;
|
|
13
|
+
cto_email: string;
|
|
14
|
+
}
|
|
15
|
+
export interface SharePayload {
|
|
16
|
+
recipients: ReportRecipients;
|
|
17
|
+
level: Exclude<ShareLevel, "none">;
|
|
18
|
+
period: {
|
|
19
|
+
start: string;
|
|
20
|
+
end: string;
|
|
21
|
+
};
|
|
22
|
+
prs: number;
|
|
23
|
+
reverts: number;
|
|
24
|
+
ci_green: number;
|
|
25
|
+
examined: number;
|
|
26
|
+
coverage_complete: boolean;
|
|
27
|
+
/** Absent when no spend line may be shown. */
|
|
28
|
+
spend_line?: string;
|
|
29
|
+
repos?: Array<{
|
|
30
|
+
repo: string;
|
|
31
|
+
prs: number;
|
|
32
|
+
reverts: number;
|
|
33
|
+
ci_green: number;
|
|
34
|
+
}>;
|
|
35
|
+
}
|
|
36
|
+
/** Builds only what the level allows. Never includes authors, titles, branches, bodies or tokens. The only person data is the two report recipients. */
|
|
37
|
+
export declare function buildSharePayload(level: Exclude<ShareLevel, "none">, r: ScanResult, recipients: ReportRecipients, spendLine?: string | null): SharePayload;
|
|
38
|
+
export declare function buildSharePayload(level: ShareLevel, r: ScanResult, recipients: ReportRecipients, spendLine?: string | null): SharePayload | null;
|