@mnemom/mnemom 0.17.38-next.0 → 0.17.38-next.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/agent-doctor.d.ts +2 -2
- package/dist/commands/agent-doctor.js +4 -4
- package/dist/commands/agent-run.d.ts +10 -2
- package/dist/commands/agent-run.js +12 -6
- package/dist/commands/agent-setup.d.ts +11 -9
- package/dist/commands/agent-setup.js +24 -19
- package/dist/commands/card.js +1 -1
- package/dist/commands/onboard.js +5 -4
- package/dist/commands/scan.d.ts +21 -7
- package/dist/commands/scan.js +93 -56
- package/dist/commands/verify-card.d.ts +25 -1
- package/dist/commands/verify-card.js +29 -2
- package/dist/commands/wrap.js +4 -3
- package/dist/index.js +7 -5
- package/dist/lib/runner/executor-client.d.ts +6 -0
- package/dist/lib/runner/executor-client.js +6 -0
- package/dist/lib/runner/job.d.ts +12 -3
- package/dist/lib/runner/job.js +30 -2
- package/dist/lib/scan/caps.d.ts +1 -35
- package/dist/lib/scan/caps.js +2 -67
- package/dist/lib/scan/cost-per-pr.d.ts +1 -93
- package/dist/lib/scan/cost-per-pr.js +2 -101
- package/dist/lib/scan/credential.d.ts +2 -26
- package/dist/lib/scan/credential.js +5 -58
- package/dist/lib/scan/failure.d.ts +1 -10
- package/dist/lib/scan/failure.js +2 -43
- package/dist/lib/scan/pr-source.d.ts +1 -57
- package/dist/lib/scan/pr-source.js +2 -132
- package/dist/lib/scan/report-email.d.ts +9 -89
- package/dist/lib/scan/report-email.js +76 -92
- package/dist/lib/scan/scan.d.ts +1 -67
- package/dist/lib/scan/scan.js +2 -194
- package/dist/lib/scan/share.d.ts +10 -6
- package/dist/lib/scan/share.js +28 -11
- package/dist/lib/scan/spend/ai-tool-detect.d.ts +1 -6
- package/dist/lib/scan/spend/ai-tool-detect.js +2 -18
- package/dist/lib/scan/spend/anthropic-usage.d.ts +1 -76
- package/dist/lib/scan/spend/anthropic-usage.js +2 -269
- package/dist/lib/scan/spend/prices.d.ts +1 -33
- package/dist/lib/scan/spend/prices.js +2 -57
- package/dist/lib/scan/spend/spend-estimate.d.ts +1 -30
- package/dist/lib/scan/spend/spend-estimate.js +2 -70
- package/dist/lib/scan/spend/spend-tiers.d.ts +1 -16
- package/dist/lib/scan/spend/spend-tiers.js +6 -26
- package/dist/lib/scan/spend/token-rate.d.ts +1 -30
- package/dist/lib/scan/spend/token-rate.js +2 -54
- package/dist/lib/starter-integrity.d.ts +20 -0
- package/dist/lib/starter-integrity.js +28 -0
- package/dist/vendor/shared/org-scan-core/caps.d.ts +35 -0
- package/dist/vendor/shared/org-scan-core/caps.js +68 -0
- package/dist/vendor/shared/org-scan-core/cost-per-pr.d.ts +93 -0
- package/dist/vendor/shared/org-scan-core/cost-per-pr.js +101 -0
- package/dist/vendor/shared/org-scan-core/credential.d.ts +43 -0
- package/dist/vendor/shared/org-scan-core/credential.js +74 -0
- package/dist/vendor/shared/org-scan-core/failure.d.ts +10 -0
- package/dist/vendor/shared/org-scan-core/failure.js +43 -0
- package/dist/vendor/shared/org-scan-core/index.d.ts +18 -0
- package/dist/vendor/shared/org-scan-core/index.js +19 -0
- package/dist/vendor/shared/org-scan-core/pr-source.d.ts +61 -0
- package/dist/vendor/shared/org-scan-core/pr-source.js +138 -0
- package/dist/vendor/shared/org-scan-core/report-build.d.ts +120 -0
- package/dist/vendor/shared/org-scan-core/report-build.js +119 -0
- package/dist/vendor/shared/org-scan-core/scan.d.ts +75 -0
- package/dist/vendor/shared/org-scan-core/scan.js +202 -0
- package/dist/vendor/shared/org-scan-core/spend/ai-tool-detect.d.ts +6 -0
- package/dist/vendor/shared/org-scan-core/spend/ai-tool-detect.js +18 -0
- package/dist/vendor/shared/org-scan-core/spend/anthropic-usage.d.ts +76 -0
- package/dist/vendor/shared/org-scan-core/spend/anthropic-usage.js +270 -0
- package/dist/{lib/scan → vendor/shared/org-scan-core}/spend/cost-corpus.v1.js +1 -0
- package/dist/vendor/shared/org-scan-core/spend/prices.d.ts +33 -0
- package/dist/vendor/shared/org-scan-core/spend/prices.js +58 -0
- package/dist/vendor/shared/org-scan-core/spend/spend-estimate.d.ts +30 -0
- package/dist/vendor/shared/org-scan-core/spend/spend-estimate.js +71 -0
- package/dist/vendor/shared/org-scan-core/spend/spend-select.d.ts +16 -0
- package/dist/vendor/shared/org-scan-core/spend/spend-select.js +26 -0
- package/dist/vendor/shared/org-scan-core/spend/token-rate.d.ts +30 -0
- package/dist/vendor/shared/org-scan-core/spend/token-rate.js +55 -0
- package/package.json +2 -2
- /package/dist/{lib/scan → vendor/shared/org-scan-core}/spend/cost-corpus.v1.d.ts +0 -0
|
@@ -1,93 +1,10 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
import type { ShareLevel } from "./share.js";
|
|
4
|
-
export declare const REPORT_SCHEMA_VERSION = "org-scan-report/1";
|
|
5
|
-
export declare const ESTIMATE_NAME = "automatable_work_share";
|
|
6
|
-
export declare const METHOD_URL = "https://www.mnemom.ai/org-scan-method";
|
|
7
|
-
export interface EmailReport {
|
|
8
|
-
schema_version: typeof REPORT_SCHEMA_VERSION;
|
|
9
|
-
org_id: string;
|
|
10
|
-
scan_id: string;
|
|
11
|
-
period: {
|
|
12
|
-
start: string;
|
|
13
|
-
end: string;
|
|
14
|
-
};
|
|
15
|
-
generated_at: string;
|
|
16
|
-
measured: {
|
|
17
|
-
teams: [];
|
|
18
|
-
summary: {
|
|
19
|
-
pr_count: number;
|
|
20
|
-
revert_count: number;
|
|
21
|
-
automatable_count: number;
|
|
22
|
-
examined_count: number;
|
|
23
|
-
tokens_total: 0;
|
|
24
|
-
};
|
|
25
|
-
spend: {
|
|
26
|
-
source: "pr_estimate";
|
|
27
|
-
figure?: {
|
|
28
|
-
low_cents: number;
|
|
29
|
-
high_cents: number;
|
|
30
|
-
};
|
|
31
|
-
ai_prs_by_tool?: Array<{
|
|
32
|
-
tool: string;
|
|
33
|
-
count: number;
|
|
34
|
-
}>;
|
|
35
|
-
};
|
|
36
|
-
};
|
|
37
|
-
estimate: {
|
|
38
|
-
name: string;
|
|
39
|
-
value: number;
|
|
40
|
-
unit: string;
|
|
41
|
-
method_url: string;
|
|
42
|
-
};
|
|
43
|
-
/**
|
|
44
|
-
* Optional, added after org-scan-report/1 shipped; absent whenever it cannot be computed (no spend,
|
|
45
|
-
* no merged PR in the period, an incomplete scan, an empty bucket). See cost-per-pr.ts.
|
|
46
|
-
* cost_per_merged_pr average cost of one merged PR in the period
|
|
47
|
-
* cost_by_pr_size { small | medium | large }: average cost of ONE PR of that size, per-bucket
|
|
48
|
-
* keys absent when the bucket is empty. small < 50 lines changed, medium 50..500, large > 500.
|
|
49
|
-
* A figure is { label: "estimate", spend_source, pr_scope, pr_count } (+ spend_scope: "whole_org" on
|
|
50
|
-
* anthropic_usage) plus { value_cents } (anthropic_usage) or { low_cents, high_cents } (modelled_range). Integer cents.
|
|
51
|
-
* pr_scope is "ai_assisted" for modelled_range (spend and size buckets split across AI-assisted PRs only;
|
|
52
|
-
* absent if none) and "all_merged" otherwise; pr_count counts PRs in that scope.
|
|
53
|
-
* Always attached when present. The API's report validator (mnemom-api src/org-scan/report-schema.ts) is
|
|
54
|
-
* closed, so an API that does not yet list these keys answers 400 invalid_report; sendReportEmail then
|
|
55
|
-
* retries once without them so the report email still goes out.
|
|
56
|
-
*/
|
|
57
|
-
cost_per_merged_pr?: CostPerPr["cost_per_merged_pr"];
|
|
58
|
-
cost_by_pr_size?: CostPerPr["cost_by_pr_size"];
|
|
59
|
-
}
|
|
60
|
-
export interface ReportEmailBody {
|
|
61
|
-
report: EmailReport;
|
|
62
|
-
share_level: ShareLevel;
|
|
63
|
-
coverage: {
|
|
64
|
-
complete: boolean;
|
|
65
|
-
};
|
|
66
|
-
cto_email?: string;
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* Maps the local scan onto the route's closed report shape. Team totals stay empty (they come only from
|
|
70
|
-
* Anthropic usage, which the local scan does not carry). The estimate is the share of examined merged
|
|
71
|
-
* PRs that finished with every CI check passing. `figure` (tier 3 range) is included only when the CLI
|
|
72
|
-
* itself would let that line leave the machine (`sendSpendRange`).
|
|
73
|
-
*/
|
|
74
|
-
export declare function buildEmailReport(result: ScanResult, o: {
|
|
75
|
-
orgId: string;
|
|
76
|
-
now: () => number;
|
|
77
|
-
sendSpendRange: boolean;
|
|
78
|
-
scanId?: string;
|
|
79
|
-
/** The requested scan period (the one the spend covers), not the examined window. */
|
|
80
|
-
period?: {
|
|
81
|
-
start: string;
|
|
82
|
-
end: string;
|
|
83
|
-
};
|
|
84
|
-
/** The one spend source the cost figures are split from; null/absent leaves them out. */
|
|
85
|
-
costSpend?: CostSpend | null;
|
|
86
|
-
}): EmailReport;
|
|
87
|
-
export declare function buildEmailBody(report: EmailReport, level: ShareLevel, complete: boolean, ctoEmail?: string): ReportEmailBody;
|
|
1
|
+
import type { ReportEmailBody } from "../../vendor/shared/org-scan-core/report-build.js";
|
|
2
|
+
export { REPORT_SCHEMA_VERSION, ESTIMATE_NAME, METHOD_URL, buildEmailReport, buildEmailBody, type EmailReport, type ReportEmailBody, } from "../../vendor/shared/org-scan-core/report-build.js";
|
|
88
3
|
/** Closed set of failure names; each has fixed text. */
|
|
89
|
-
export type EmailFailure = "presend_check_failed" | "email_not_sent" | "not_signed_in" | "not_a_member" | "report_rejected" | "no_recipients" | "runner_not_emailed" | "network_error" | "unexpected_response";
|
|
90
|
-
|
|
4
|
+
export type EmailFailure = "presend_check_failed" | "email_not_sent" | "not_signed_in" | "not_a_member" | "report_rejected" | "no_recipients" | "runner_not_emailed" | "emails_rejected" | "email_domain_not_allowed" | "admins_not_found" | "scan_limit_reached" | "network_error" | "unexpected_response";
|
|
5
|
+
/** How long to wait, in plain words (rounded up to whole seconds, minutes or hours). */
|
|
6
|
+
export declare function retryWait(ms: number): string;
|
|
7
|
+
export declare const failureMessage: (f: EmailFailure, retryAfterMs?: number) => string;
|
|
91
8
|
export declare const DRY_RUN_MSG = "Report email is in dry-run on this environment; nothing was sent.";
|
|
92
9
|
export declare const UNAVAILABLE_MSG = "Report email is not available for this org yet; nothing was sent.";
|
|
93
10
|
export declare class ReportEmailError extends Error {
|
|
@@ -97,6 +14,8 @@ export declare class ReportEmailError extends Error {
|
|
|
97
14
|
export type EmailOutcome = {
|
|
98
15
|
status: "sent";
|
|
99
16
|
recipients: number;
|
|
17
|
+
adminsMissed?: number;
|
|
18
|
+
limited?: true;
|
|
100
19
|
} | {
|
|
101
20
|
status: "dry_run";
|
|
102
21
|
} | {
|
|
@@ -104,6 +23,7 @@ export type EmailOutcome = {
|
|
|
104
23
|
} | {
|
|
105
24
|
status: "failed";
|
|
106
25
|
reason: EmailFailure;
|
|
26
|
+
retryAfterMs?: number;
|
|
107
27
|
};
|
|
108
28
|
export interface ReportEmailDeps {
|
|
109
29
|
post?: (endpoint: string, body: ReportEmailBody) => Promise<unknown>;
|
|
@@ -1,87 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The report email: after every successful scan the
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* The report email: after every successful scan the full report, with the counts for each repo, is
|
|
3
|
+
* emailed to the person who ran it, every Org Admin of the org and anyone the runner added (MNE-9634),
|
|
4
|
+
* with no opt-out and at every share level. One call to `POST /v1/orgs/{org_id}/scan/report-email`.
|
|
5
|
+
* The server resolves the runner's address from the signed-in identity and the Org Admins from the org,
|
|
6
|
+
* so the request carries neither; `emails` holds only the extra addresses the runner added.
|
|
6
7
|
*
|
|
7
|
-
* The body is exactly the route's contract and nothing else: `report` (org-scan-report/1: team totals
|
|
8
|
-
* and counts
|
|
9
|
-
*
|
|
8
|
+
* The body is exactly the route's contract and nothing else: `report` (org-scan-report/1: team totals,
|
|
9
|
+
* counts, and the per-repo counts `by_repo`), `share_level`, `coverage.complete` and the optional
|
|
10
|
+
* `emails`. No author, title, branch or body text, no credential.
|
|
10
11
|
*
|
|
11
12
|
* Every failure has a fixed name and fixed text. The server's body, the thrown value and the
|
|
12
13
|
* credential never reach the terminal.
|
|
13
14
|
*/
|
|
14
|
-
import { randomUUID } from "node:crypto";
|
|
15
15
|
import { MnemomApiError, postApi } from "../api.js";
|
|
16
|
-
|
|
17
|
-
export
|
|
18
|
-
export const ESTIMATE_NAME = "automatable_work_share";
|
|
19
|
-
export const METHOD_URL = "https://www.mnemom.ai/org-scan-method";
|
|
20
|
-
const cents = (usd) => Math.max(0, Math.round(usd * 100));
|
|
21
|
-
/**
|
|
22
|
-
* Maps the local scan onto the route's closed report shape. Team totals stay empty (they come only from
|
|
23
|
-
* Anthropic usage, which the local scan does not carry). The estimate is the share of examined merged
|
|
24
|
-
* PRs that finished with every CI check passing. `figure` (tier 3 range) is included only when the CLI
|
|
25
|
-
* itself would let that line leave the machine (`sendSpendRange`).
|
|
26
|
-
*/
|
|
27
|
-
export function buildEmailReport(result, o) {
|
|
28
|
-
const { totals, coverage } = result;
|
|
29
|
-
const value = totals.examined > 0 ? Math.round((totals.ciGreen / totals.examined) * 1000) / 10 : 0;
|
|
30
|
-
const scope = coverage.complete
|
|
31
|
-
? "every merged pull request in the period"
|
|
32
|
-
: "a capped sample (not every merged pull request in the period)";
|
|
33
|
-
const unit = `percent of merged pull requests examined (${totals.examined} of ${totals.prs}, ${scope}) ` +
|
|
34
|
-
`that finished with all CI checks passing; an estimate from pull request metadata, not a measured share`;
|
|
35
|
-
const byTool = result.spend.kind === "range"
|
|
36
|
-
? Object.entries(result.spend.byTool)
|
|
37
|
-
.filter(([, n]) => typeof n === "number" && n > 0)
|
|
38
|
-
.map(([tool, count]) => ({ tool, count: count }))
|
|
39
|
-
: [];
|
|
40
|
-
// Only a scan that read every merged PR in the period gives an average that means anything.
|
|
41
|
-
const exhaustive = coverage.complete && coverage.prsSkippedLarge === 0 && coverage.prsUnreadable === 0;
|
|
42
|
-
const cost = buildCostPerPr(result.prSizes, o.period ?? { start: coverage.windowStart, end: coverage.windowEnd }, o.costSpend ?? null, exhaustive);
|
|
43
|
-
return {
|
|
44
|
-
schema_version: REPORT_SCHEMA_VERSION,
|
|
45
|
-
org_id: o.orgId,
|
|
46
|
-
scan_id: o.scanId ?? randomUUID(),
|
|
47
|
-
// The span the scan actually examined (coverage), never the requested period: with no --period
|
|
48
|
-
// that defaults to today..today, which is not what was read.
|
|
49
|
-
period: { start: coverage.windowStart, end: coverage.windowEnd },
|
|
50
|
-
generated_at: new Date(o.now()).toISOString(),
|
|
51
|
-
measured: {
|
|
52
|
-
teams: [],
|
|
53
|
-
summary: {
|
|
54
|
-
pr_count: totals.prs,
|
|
55
|
-
revert_count: totals.reverts,
|
|
56
|
-
automatable_count: totals.ciGreen,
|
|
57
|
-
examined_count: totals.examined,
|
|
58
|
-
tokens_total: 0,
|
|
59
|
-
},
|
|
60
|
-
spend: {
|
|
61
|
-
source: "pr_estimate",
|
|
62
|
-
...(o.sendSpendRange && result.spend.kind === "range"
|
|
63
|
-
? {
|
|
64
|
-
figure: {
|
|
65
|
-
low_cents: cents(result.spend.lowUsd),
|
|
66
|
-
high_cents: cents(result.spend.highUsd),
|
|
67
|
-
},
|
|
68
|
-
}
|
|
69
|
-
: {}),
|
|
70
|
-
...(o.sendSpendRange && byTool.length > 0 ? { ai_prs_by_tool: byTool } : {}),
|
|
71
|
-
},
|
|
72
|
-
},
|
|
73
|
-
estimate: { name: ESTIMATE_NAME, value, unit, method_url: METHOD_URL },
|
|
74
|
-
...cost,
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
export function buildEmailBody(report, level, complete, ctoEmail) {
|
|
78
|
-
return {
|
|
79
|
-
report,
|
|
80
|
-
share_level: level,
|
|
81
|
-
coverage: { complete },
|
|
82
|
-
...(ctoEmail !== undefined ? { cto_email: ctoEmail } : {}),
|
|
83
|
-
};
|
|
84
|
-
}
|
|
16
|
+
// The report body is built by the shared core (MNE-9752), so the Mnemom-side run sends the same report.
|
|
17
|
+
export { REPORT_SCHEMA_VERSION, ESTIMATE_NAME, METHOD_URL, buildEmailReport, buildEmailBody, } from "../../vendor/shared/org-scan-core/report-build.js";
|
|
85
18
|
const FAILURE_TEXT = {
|
|
86
19
|
presend_check_failed: "the report failed Mnemom's pre-send checks, so no email was sent.",
|
|
87
20
|
email_not_sent: "Mnemom could not send the email.",
|
|
@@ -89,11 +22,30 @@ const FAILURE_TEXT = {
|
|
|
89
22
|
not_a_member: "your account is not a member of the org passed to --org.",
|
|
90
23
|
report_rejected: "Mnemom rejected the report as malformed.",
|
|
91
24
|
no_recipients: "the server accepted the report but reported zero recipients.",
|
|
92
|
-
runner_not_emailed: "the report was not delivered to you (the person who ran the scan); it may have gone only to the
|
|
25
|
+
runner_not_emailed: "the report was not delivered to you (the person who ran the scan); it may have gone only to the others. Re-run the scan, and check that your account has a valid email address.",
|
|
26
|
+
emails_rejected: "an address you added was not accepted. Add up to 10 valid email addresses.",
|
|
27
|
+
email_domain_not_allowed: "an address you added is not on your organization's email domain. Add addresses on your own domain or mnemom.ai.",
|
|
28
|
+
admins_not_found: "Mnemom could not look up your org admins, so no email was sent. Try again in a moment.",
|
|
29
|
+
scan_limit_reached: "the scan limit was reached, so no email was sent.",
|
|
93
30
|
network_error: "Mnemom could not be reached.",
|
|
94
31
|
unexpected_response: "Mnemom answered in an unexpected way.",
|
|
95
32
|
};
|
|
96
|
-
|
|
33
|
+
/** How long to wait, in plain words (rounded up to whole seconds, minutes or hours). */
|
|
34
|
+
export function retryWait(ms) {
|
|
35
|
+
const s = Math.max(1, Math.ceil(ms / 1000));
|
|
36
|
+
if (s < 60)
|
|
37
|
+
return `${s} second${s === 1 ? "" : "s"}`;
|
|
38
|
+
const m = Math.ceil(s / 60);
|
|
39
|
+
if (m < 120)
|
|
40
|
+
return `${m} minute${m === 1 ? "" : "s"}`;
|
|
41
|
+
const h = Math.ceil(m / 60);
|
|
42
|
+
return `${h} hour${h === 1 ? "" : "s"}`;
|
|
43
|
+
}
|
|
44
|
+
export const failureMessage = (f, retryAfterMs) => f === "scan_limit_reached"
|
|
45
|
+
? `Your report was NOT emailed: ${f}. ${FAILURE_TEXT[f]} ${retryAfterMs !== undefined
|
|
46
|
+
? `Try again in about ${retryWait(retryAfterMs)}.`
|
|
47
|
+
: "Try again later."}`
|
|
48
|
+
: `Your report was NOT emailed: ${f}. ${FAILURE_TEXT[f]}`;
|
|
97
49
|
export const DRY_RUN_MSG = "Report email is in dry-run on this environment; nothing was sent.";
|
|
98
50
|
export const UNAVAILABLE_MSG = "Report email is not available for this org yet; nothing was sent.";
|
|
99
51
|
export class ReportEmailError extends Error {
|
|
@@ -110,6 +62,8 @@ function classifyStatus(status) {
|
|
|
110
62
|
return "not_signed_in";
|
|
111
63
|
if (status === 403)
|
|
112
64
|
return "not_a_member";
|
|
65
|
+
if (status === 429)
|
|
66
|
+
return "scan_limit_reached";
|
|
113
67
|
if (status === 422)
|
|
114
68
|
return "presend_check_failed";
|
|
115
69
|
if (status === 502)
|
|
@@ -118,11 +72,27 @@ function classifyStatus(status) {
|
|
|
118
72
|
return "report_rejected";
|
|
119
73
|
return "unexpected_response";
|
|
120
74
|
}
|
|
121
|
-
/**
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
75
|
+
/**
|
|
76
|
+
* The body an API that predates this contract understands: no cost figures, no per-repo rows, and the
|
|
77
|
+
* share level under its old name (`analytics` was `summary`; the meaning is the same, never upgraded).
|
|
78
|
+
* The same object when nothing needs changing.
|
|
79
|
+
*/
|
|
80
|
+
function legacyBody(body) {
|
|
81
|
+
const { cost_per_merged_pr: _a, cost_by_pr_size: _b, by_repo: _c, ...rest } = body.report;
|
|
82
|
+
const changed = "cost_per_merged_pr" in body.report ||
|
|
83
|
+
"cost_by_pr_size" in body.report ||
|
|
84
|
+
"by_repo" in body.report ||
|
|
85
|
+
body.share_level === "analytics" ||
|
|
86
|
+
(body.emails?.length ?? 0) > 0;
|
|
87
|
+
const { emails, ...top } = body;
|
|
88
|
+
const extra = emails?.[0];
|
|
89
|
+
return changed
|
|
90
|
+
? {
|
|
91
|
+
...top,
|
|
92
|
+
...(extra ? { cto_email: extra } : {}),
|
|
93
|
+
report: rest,
|
|
94
|
+
share_level: body.share_level === "analytics" ? "summary" : body.share_level,
|
|
95
|
+
}
|
|
126
96
|
: body;
|
|
127
97
|
}
|
|
128
98
|
/** Never throws. The thrown value, the response body and the credential are never surfaced. */
|
|
@@ -135,18 +105,29 @@ export async function sendReportEmail(orgId, body, deps = {}) {
|
|
|
135
105
|
catch (e) {
|
|
136
106
|
if (e instanceof MnemomApiError && e.effectiveStatus === 404)
|
|
137
107
|
return { status: "unavailable" };
|
|
138
|
-
// An API that predates the cost figures
|
|
139
|
-
// go out, so retry once
|
|
108
|
+
// An API that predates the cost figures, the per-repo rows or the analytics name rejects them with a 400. The email must still
|
|
109
|
+
// go out, so retry once in the old shape; any other failure is classified as below.
|
|
140
110
|
if (e instanceof MnemomApiError && e.effectiveStatus === 400) {
|
|
141
|
-
const bare =
|
|
142
|
-
if (bare !== body)
|
|
143
|
-
|
|
111
|
+
const bare = legacyBody(body);
|
|
112
|
+
if (bare !== body && !/email|admin/i.test(e.code ?? "")) {
|
|
113
|
+
const r = await sendReportEmail(orgId, bare, deps);
|
|
114
|
+
return r.status === "sent" ? { ...r, limited: true } : r;
|
|
115
|
+
}
|
|
144
116
|
}
|
|
145
117
|
if (e instanceof MnemomApiError) {
|
|
146
118
|
// A 4xx that names the runner (the API refuses a send that would skip the runner) gets its own text.
|
|
119
|
+
if (e.code === "email_domain_not_allowed")
|
|
120
|
+
return { status: "failed", reason: "email_domain_not_allowed" };
|
|
121
|
+
if (/emails/i.test(e.code ?? ""))
|
|
122
|
+
return { status: "failed", reason: "emails_rejected" };
|
|
123
|
+
if (e.code === "admin_lookup_failed")
|
|
124
|
+
return { status: "failed", reason: "admins_not_found" };
|
|
147
125
|
if (e.effectiveStatus >= 400 && e.effectiveStatus < 500 && /runner/i.test(e.code ?? ""))
|
|
148
126
|
return { status: "failed", reason: "runner_not_emailed" };
|
|
149
|
-
|
|
127
|
+
const reason = classifyStatus(e.effectiveStatus);
|
|
128
|
+
return reason === "scan_limit_reached" && e.retryAfterMs !== undefined
|
|
129
|
+
? { status: "failed", reason, retryAfterMs: e.retryAfterMs }
|
|
130
|
+
: { status: "failed", reason };
|
|
150
131
|
}
|
|
151
132
|
return { status: "failed", reason: "network_error" };
|
|
152
133
|
}
|
|
@@ -158,8 +139,11 @@ export async function sendReportEmail(orgId, body, deps = {}) {
|
|
|
158
139
|
return { status: "failed", reason: "unexpected_response" };
|
|
159
140
|
if (recipients === 0)
|
|
160
141
|
return { status: "failed", reason: "no_recipients" };
|
|
161
|
-
// `skipped` lists the roles ("runner", "cto") that got no copy. The runner must always get the report.
|
|
142
|
+
// `skipped` lists the roles ("runner", "admin", "added", "cto") that got no copy. The runner must always get the report.
|
|
162
143
|
if (Array.isArray(o.skipped) && o.skipped.includes("runner"))
|
|
163
144
|
return { status: "failed", reason: "runner_not_emailed" };
|
|
164
|
-
|
|
145
|
+
const adminsMissed = Array.isArray(o.skipped) ? o.skipped.filter((s) => s === "admin").length : 0;
|
|
146
|
+
return o.status === "dry_run"
|
|
147
|
+
? { status: "dry_run" }
|
|
148
|
+
: { status: "sent", recipients, ...(adminsMissed > 0 ? { adminsMissed } : {}) };
|
|
165
149
|
}
|
package/dist/lib/scan/scan.d.ts
CHANGED
|
@@ -1,67 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* Local capped scan core. Port of the window path of runCappedScan (mnemom-api capped-scan.ts, PR #4208)
|
|
3
|
-
* plus the spend estimate (#4222). Everything outside is injected (credential, PR source, clock), so the
|
|
4
|
-
* core touches no network or disk, and the report exists only in memory.
|
|
5
|
-
*/
|
|
6
|
-
import { type GitHubCredential } from "./credential.js";
|
|
7
|
-
import { type ScanCaps, type StopReason } from "./caps.js";
|
|
8
|
-
import { type FailureReason } from "./failure.js";
|
|
9
|
-
import { type PrSource } from "./pr-source.js";
|
|
10
|
-
import type { PrSize } from "./cost-per-pr.js";
|
|
11
|
-
import { type SpendEstimate } from "./spend/spend-estimate.js";
|
|
12
|
-
export interface ScanInput {
|
|
13
|
-
repos: string[];
|
|
14
|
-
/** Inclusive dates, YYYY-MM-DD. */
|
|
15
|
-
period: {
|
|
16
|
-
start: string;
|
|
17
|
-
end: string;
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
export interface ScanDeps {
|
|
21
|
-
credential: GitHubCredential;
|
|
22
|
-
prs?: PrSource;
|
|
23
|
-
fetchImpl?: typeof fetch;
|
|
24
|
-
now?: () => number;
|
|
25
|
-
caps?: Partial<ScanCaps>;
|
|
26
|
-
}
|
|
27
|
-
export interface Coverage {
|
|
28
|
-
complete: boolean;
|
|
29
|
-
stoppedBy: StopReason | null;
|
|
30
|
-
prsExamined: number;
|
|
31
|
-
prsSkippedLarge: number;
|
|
32
|
-
prsUnreadable: number;
|
|
33
|
-
reposRead: number;
|
|
34
|
-
reposTotal: number;
|
|
35
|
-
reads: number;
|
|
36
|
-
windowStart: string;
|
|
37
|
-
windowEnd: string;
|
|
38
|
-
/** Set only when the window held more merged PRs than the quota and an even sample was taken. */
|
|
39
|
-
windowSampledFrom?: number;
|
|
40
|
-
}
|
|
41
|
-
export interface RepoTotals {
|
|
42
|
-
repo: string;
|
|
43
|
-
prs: number;
|
|
44
|
-
reverts: number;
|
|
45
|
-
ciGreen: number;
|
|
46
|
-
}
|
|
47
|
-
export interface ScanResult {
|
|
48
|
-
coverage: Coverage;
|
|
49
|
-
totals: {
|
|
50
|
-
prs: number;
|
|
51
|
-
reverts: number;
|
|
52
|
-
ciGreen: number;
|
|
53
|
-
examined: number;
|
|
54
|
-
};
|
|
55
|
-
byRepo: RepoTotals[];
|
|
56
|
-
spend: SpendEstimate;
|
|
57
|
-
/** Every merged PR read in full (reverts and unreadable/oversize PRs excluded): merge day and size. In memory only. */
|
|
58
|
-
prSizes: PrSize[];
|
|
59
|
-
}
|
|
60
|
-
export type ScanOutcome = {
|
|
61
|
-
status: "done";
|
|
62
|
-
result: ScanResult;
|
|
63
|
-
} | {
|
|
64
|
-
status: "withheld";
|
|
65
|
-
reason: FailureReason;
|
|
66
|
-
};
|
|
67
|
-
export declare function runLocalScan(deps: ScanDeps, input: ScanInput): Promise<ScanOutcome>;
|
|
1
|
+
export * from "../../vendor/shared/org-scan-core/scan.js";
|
package/dist/lib/scan/scan.js
CHANGED
|
@@ -1,194 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
*
|
|
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
|
-
const prSizes = [];
|
|
113
|
-
let skippedLarge = 0;
|
|
114
|
-
let unreadable = 0;
|
|
115
|
-
for (const { repo, item } of chosen) {
|
|
116
|
-
// A revert PR is never a candidate: counted from the listing, never read.
|
|
117
|
-
if (item.reverted) {
|
|
118
|
-
totals.prs += 1;
|
|
119
|
-
totals.reverts += 1;
|
|
120
|
-
bucket(repo).prs += 1;
|
|
121
|
-
bucket(repo).reverts += 1;
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
if (stop(budget.block(2)))
|
|
125
|
-
break;
|
|
126
|
-
budget.charge(1);
|
|
127
|
-
const res = await prs.readPr(repo, item, budget.room() + 1);
|
|
128
|
-
budget.charge((Number.isFinite(res.reads) && res.reads >= 1 ? res.reads : 1) - 1);
|
|
129
|
-
const overrun = budget.overrun();
|
|
130
|
-
if (overrun) {
|
|
131
|
-
stop(overrun);
|
|
132
|
-
break;
|
|
133
|
-
}
|
|
134
|
-
const rec = res.pr;
|
|
135
|
-
if (!rec) {
|
|
136
|
-
unreadable += 1;
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
if (rec.additions + rec.deletions > caps.maxPrSizeLines) {
|
|
140
|
-
skippedLarge += 1;
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
totals.prs += 1;
|
|
144
|
-
totals.examined += 1;
|
|
145
|
-
bucket(repo).prs += 1;
|
|
146
|
-
if (rec.ci === "success") {
|
|
147
|
-
totals.ciGreen += 1;
|
|
148
|
-
bucket(repo).ciGreen += 1;
|
|
149
|
-
}
|
|
150
|
-
prSizes.push({
|
|
151
|
-
mergedDay: item.mergedDay,
|
|
152
|
-
lines: rec.additions + rec.deletions,
|
|
153
|
-
aiAssisted: (rec.tools?.length ?? 0) > 0,
|
|
154
|
-
});
|
|
155
|
-
spendInputs.push({
|
|
156
|
-
additions: rec.additions,
|
|
157
|
-
deletions: rec.deletions,
|
|
158
|
-
markText: "",
|
|
159
|
-
tools: rec.tools,
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
const coverage = {
|
|
163
|
-
complete: stoppedBy === null &&
|
|
164
|
-
!listingCut &&
|
|
165
|
-
unreadable === 0 &&
|
|
166
|
-
reposRead === input.repos.length &&
|
|
167
|
-
!sampled,
|
|
168
|
-
stoppedBy: stoppedBy ?? (listingCut ? "read-cap" : null),
|
|
169
|
-
prsExamined: totals.examined,
|
|
170
|
-
prsSkippedLarge: skippedLarge,
|
|
171
|
-
prsUnreadable: unreadable,
|
|
172
|
-
reposRead,
|
|
173
|
-
reposTotal: input.repos.length,
|
|
174
|
-
reads: budget.reads,
|
|
175
|
-
windowStart,
|
|
176
|
-
windowEnd: endDay,
|
|
177
|
-
...(sampled ? { windowSampledFrom: inWindow.length } : {}),
|
|
178
|
-
};
|
|
179
|
-
return {
|
|
180
|
-
status: "done",
|
|
181
|
-
result: {
|
|
182
|
-
coverage,
|
|
183
|
-
totals,
|
|
184
|
-
byRepo: [...byRepo.values()],
|
|
185
|
-
spend: estimateSpend(spendInputs),
|
|
186
|
-
prSizes,
|
|
187
|
-
},
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
|
-
catch (e) {
|
|
191
|
-
// Reads only; the raw error is dropped and a fixed reason returned.
|
|
192
|
-
return { status: "withheld", reason: classifyFailure(e) };
|
|
193
|
-
}
|
|
194
|
-
}
|
|
1
|
+
// Moved to shared/org-scan-core (MNE-9752); vendored at cli/src/vendor/shared/org-scan-core. Re-exported so CLI imports stay put.
|
|
2
|
+
export * from "../../vendor/shared/org-scan-core/scan.js";
|
package/dist/lib/scan/share.d.ts
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
import type { ScanResult } from "./scan.js";
|
|
2
|
-
|
|
2
|
+
import type { ShareLevel } from "../../vendor/shared/org-scan-core/report-build.js";
|
|
3
|
+
/** Defined in the shared core (MNE-9752); re-exported for the CLI's callers. */
|
|
4
|
+
export type { ShareLevel };
|
|
3
5
|
/** The level preselected at the prompt (Enter picks it). */
|
|
4
6
|
export declare const DEFAULT_SHARE_LEVEL: ShareLevel;
|
|
5
7
|
export declare const SHARE_OPTIONS: ReadonlyArray<{
|
|
6
8
|
level: ShareLevel;
|
|
7
9
|
label: string;
|
|
8
10
|
}>;
|
|
9
|
-
/** Shown just above the
|
|
11
|
+
/** Shown just above the prompt. The full report, repo names included, is emailed whatever is chosen here. */
|
|
10
12
|
export declare const SHARE_DISCLOSURE: string;
|
|
11
13
|
/** Asks the user, with Full preselected. Anything but a recognised answer (cancel, EOF, noise) is "none": fail closed. */
|
|
12
14
|
export declare function promptShareLevel(): Promise<ShareLevel>;
|
|
15
|
+
/** `summary` is the old name for `analytics`; the meaning is unchanged. */
|
|
13
16
|
export declare function parseShareLevel(v: string | undefined): ShareLevel | null;
|
|
14
|
-
/** Who receives the report.
|
|
17
|
+
/** Who receives the report. The runner comes from the Mnemom login; the server resolves the Org Admins. */
|
|
15
18
|
export interface ReportRecipients {
|
|
16
|
-
runner_email
|
|
17
|
-
|
|
19
|
+
runner_email?: string;
|
|
20
|
+
extra_emails: string[];
|
|
18
21
|
}
|
|
19
22
|
export interface SharePayload {
|
|
20
23
|
recipients: ReportRecipients;
|
|
@@ -30,6 +33,7 @@ export interface SharePayload {
|
|
|
30
33
|
coverage_complete: boolean;
|
|
31
34
|
/** Absent when no spend line may be shown. */
|
|
32
35
|
spend_line?: string;
|
|
36
|
+
/** Present at "full" only. */
|
|
33
37
|
repos?: Array<{
|
|
34
38
|
repo: string;
|
|
35
39
|
prs: number;
|
|
@@ -37,6 +41,6 @@ export interface SharePayload {
|
|
|
37
41
|
ci_green: number;
|
|
38
42
|
}>;
|
|
39
43
|
}
|
|
40
|
-
/** Builds only what the level allows. Never includes authors, titles, branches, bodies or tokens. The only person data is the
|
|
44
|
+
/** Builds only what the level allows. Never includes authors, titles, branches, bodies or tokens. The only person data is the report recipients. */
|
|
41
45
|
export declare function buildSharePayload(level: Exclude<ShareLevel, "none">, r: ScanResult, recipients: ReportRecipients, spendLine?: string | null): SharePayload;
|
|
42
46
|
export declare function buildSharePayload(level: ShareLevel, r: ScanResult, recipients: ReportRecipients, spendLine?: string | null): SharePayload | null;
|