@mnemom/mnemom 0.17.38-next.1 → 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-run.d.ts +10 -2
- package/dist/commands/agent-run.js +12 -6
- package/dist/commands/scan.d.ts +1 -1
- package/dist/commands/scan.js +7 -14
- 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 +7 -104
- package/dist/lib/scan/report-email.js +25 -97
- package/dist/lib/scan/scan.d.ts +1 -69
- package/dist/lib/scan/scan.js +2 -195
- package/dist/lib/scan/share.d.ts +3 -1
- 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/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 +1 -1
- /package/dist/{lib/scan → vendor/shared/org-scan-core}/spend/cost-corpus.v1.d.ts +0 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHubCredential: the one seam through which a scan obtains a GitHub token.
|
|
3
|
+
*
|
|
4
|
+
* The scan core only ever sees this interface. The CLI's default is the GitHub login of whoever runs the
|
|
5
|
+
* scan (`gh auth token`, cli/src/lib/scan/credential.ts); the Mnemom-side run (mnemom-api, MNE-9743)
|
|
6
|
+
* supplies a read-only installation token. Port of the read-only rule in mnemom-api
|
|
7
|
+
* src/org-scan/github-scan-app.ts. Runtime-neutral: no Node or Worker API.
|
|
8
|
+
*/
|
|
9
|
+
/** The complete permission set the CLI's scan credential may hold. Read only. */
|
|
10
|
+
export declare const SCAN_READ_PERMISSIONS: Readonly<Record<string, "read">>;
|
|
11
|
+
/**
|
|
12
|
+
* The complete permission set the Mnemom-side run's App token may hold (org-scan threat model): no
|
|
13
|
+
* `contents`. `checks` is what the check-runs read needs on an App installation token.
|
|
14
|
+
*/
|
|
15
|
+
export declare const SERVER_SCAN_READ_PERMISSIONS: Readonly<Record<string, "read">>;
|
|
16
|
+
export interface GitHubCredential {
|
|
17
|
+
/** Fixed label for diagnostics ("app", "personal"): never a token, login or URL. */
|
|
18
|
+
readonly kind: string;
|
|
19
|
+
/** A bearer token for reading `repo` (owner/name). A rejection fails that read closed. */
|
|
20
|
+
tokenFor(repo: string): Promise<string>;
|
|
21
|
+
/**
|
|
22
|
+
* The permissions GitHub granted this credential, when the source can tell. Null = unknown (for
|
|
23
|
+
* example a classic token); the scan then relies on GET-only egress and does not claim more.
|
|
24
|
+
*/
|
|
25
|
+
grantedPermissions?(): Promise<Record<string, string> | null>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Violations (empty when the map is exactly the read-only set `allowed`, by default the CLI's set).
|
|
29
|
+
* Port of readOnlyViolations.
|
|
30
|
+
*/
|
|
31
|
+
export declare function readOnlyViolations(permissions: Record<string, string> | null | undefined, allowed?: Readonly<Record<string, "read">>): string[];
|
|
32
|
+
/** owner/name, no dot-only segment. */
|
|
33
|
+
export declare function isValidRepoSlug(s: unknown): s is string;
|
|
34
|
+
/** Fixed-text failure: the message never carries a token, a body or a URL. */
|
|
35
|
+
export declare class ScanCredentialError extends Error {
|
|
36
|
+
constructor(message: "scan credential refused" | "scan credential not configured");
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Refuses a credential GitHub granted anything other than exactly `allowed` (read only) to. Throws a
|
|
40
|
+
* fixed-text error and nothing else. Unknown (null) permissions pass: the source cannot tell, and the
|
|
41
|
+
* scan is GET-only regardless.
|
|
42
|
+
*/
|
|
43
|
+
export declare function assertReadOnlyCredential(c: GitHubCredential, allowed?: Readonly<Record<string, "read">>): Promise<void>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// GENERATED from shared/org-scan-core/src/credential.ts by cli/scripts/sync-org-scan-core.mjs; do not edit.
|
|
2
|
+
/**
|
|
3
|
+
* GitHubCredential: the one seam through which a scan obtains a GitHub token.
|
|
4
|
+
*
|
|
5
|
+
* The scan core only ever sees this interface. The CLI's default is the GitHub login of whoever runs the
|
|
6
|
+
* scan (`gh auth token`, cli/src/lib/scan/credential.ts); the Mnemom-side run (mnemom-api, MNE-9743)
|
|
7
|
+
* supplies a read-only installation token. Port of the read-only rule in mnemom-api
|
|
8
|
+
* src/org-scan/github-scan-app.ts. Runtime-neutral: no Node or Worker API.
|
|
9
|
+
*/
|
|
10
|
+
/** The complete permission set the CLI's 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
|
+
/**
|
|
17
|
+
* The complete permission set the Mnemom-side run's App token may hold (org-scan threat model): no
|
|
18
|
+
* `contents`. `checks` is what the check-runs read needs on an App installation token.
|
|
19
|
+
*/
|
|
20
|
+
export const SERVER_SCAN_READ_PERMISSIONS = Object.freeze({
|
|
21
|
+
metadata: "read",
|
|
22
|
+
pull_requests: "read",
|
|
23
|
+
checks: "read",
|
|
24
|
+
});
|
|
25
|
+
/**
|
|
26
|
+
* Violations (empty when the map is exactly the read-only set `allowed`, by default the CLI's set).
|
|
27
|
+
* Port of readOnlyViolations.
|
|
28
|
+
*/
|
|
29
|
+
export function readOnlyViolations(permissions, allowed = SCAN_READ_PERMISSIONS) {
|
|
30
|
+
const out = [];
|
|
31
|
+
const p = permissions ?? {};
|
|
32
|
+
for (const [name, level] of Object.entries(p)) {
|
|
33
|
+
if (level !== "read")
|
|
34
|
+
out.push(`${name}:${level} is not read`);
|
|
35
|
+
if (!(name in allowed))
|
|
36
|
+
out.push(`${name} is not in the scan permission set`);
|
|
37
|
+
}
|
|
38
|
+
for (const name of Object.keys(allowed)) {
|
|
39
|
+
if (!(name in p))
|
|
40
|
+
out.push(`${name} missing`);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/** owner/name, no dot-only segment. */
|
|
45
|
+
export function isValidRepoSlug(s) {
|
|
46
|
+
return (typeof s === "string" &&
|
|
47
|
+
/^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/.test(s) &&
|
|
48
|
+
!s.split("/").some((p) => /^\.+$/.test(p)));
|
|
49
|
+
}
|
|
50
|
+
/** Fixed-text failure: the message never carries a token, a body or a URL. */
|
|
51
|
+
export class ScanCredentialError extends Error {
|
|
52
|
+
constructor(message) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = "ScanCredentialError";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Refuses a credential GitHub granted anything other than exactly `allowed` (read only) to. Throws a
|
|
59
|
+
* fixed-text error and nothing else. Unknown (null) permissions pass: the source cannot tell, and the
|
|
60
|
+
* scan is GET-only regardless.
|
|
61
|
+
*/
|
|
62
|
+
export async function assertReadOnlyCredential(c, allowed = SCAN_READ_PERMISSIONS) {
|
|
63
|
+
if (!c.grantedPermissions)
|
|
64
|
+
return;
|
|
65
|
+
let perms;
|
|
66
|
+
try {
|
|
67
|
+
perms = await c.grantedPermissions();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new ScanCredentialError("scan credential refused");
|
|
71
|
+
}
|
|
72
|
+
if (perms !== null && readOnlyViolations(perms, allowed).length > 0)
|
|
73
|
+
throw new ScanCredentialError("scan credential refused");
|
|
74
|
+
}
|
|
@@ -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,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mnemom/org-scan-core (MNE-9752): the org scan pipeline shared by the CLI and the Mnemom-side run.
|
|
3
|
+
* Runtime-neutral: GitHub reads go through an injected fetch and credential, time through an injected
|
|
4
|
+
* clock, cancellation through an injected AbortSignal. Nothing here stores or sends a report.
|
|
5
|
+
*/
|
|
6
|
+
export * from "./caps.js";
|
|
7
|
+
export * from "./credential.js";
|
|
8
|
+
export * from "./failure.js";
|
|
9
|
+
export * from "./pr-source.js";
|
|
10
|
+
export * from "./scan.js";
|
|
11
|
+
export * from "./cost-per-pr.js";
|
|
12
|
+
export * from "./report-build.js";
|
|
13
|
+
export * from "./spend/ai-tool-detect.js";
|
|
14
|
+
export * from "./spend/prices.js";
|
|
15
|
+
export * from "./spend/token-rate.js";
|
|
16
|
+
export * from "./spend/spend-estimate.js";
|
|
17
|
+
export * from "./spend/spend-select.js";
|
|
18
|
+
export * from "./spend/anthropic-usage.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// GENERATED from shared/org-scan-core/src/index.ts by cli/scripts/sync-org-scan-core.mjs; do not edit.
|
|
2
|
+
/**
|
|
3
|
+
* @mnemom/org-scan-core (MNE-9752): the org scan pipeline shared by the CLI and the Mnemom-side run.
|
|
4
|
+
* Runtime-neutral: GitHub reads go through an injected fetch and credential, time through an injected
|
|
5
|
+
* clock, cancellation through an injected AbortSignal. Nothing here stores or sends a report.
|
|
6
|
+
*/
|
|
7
|
+
export * from "./caps.js";
|
|
8
|
+
export * from "./credential.js";
|
|
9
|
+
export * from "./failure.js";
|
|
10
|
+
export * from "./pr-source.js";
|
|
11
|
+
export * from "./scan.js";
|
|
12
|
+
export * from "./cost-per-pr.js";
|
|
13
|
+
export * from "./report-build.js";
|
|
14
|
+
export * from "./spend/ai-tool-detect.js";
|
|
15
|
+
export * from "./spend/prices.js";
|
|
16
|
+
export * from "./spend/token-rate.js";
|
|
17
|
+
export * from "./spend/spend-estimate.js";
|
|
18
|
+
export * from "./spend/spend-select.js";
|
|
19
|
+
export * from "./spend/anthropic-usage.js";
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
/** Aborts every read (the caller's run deadline), on top of the per-read timeout. */
|
|
51
|
+
signal?: AbortSignal;
|
|
52
|
+
/** Sent as user-agent; defaults to the CLI's. */
|
|
53
|
+
userAgent?: string;
|
|
54
|
+
}
|
|
55
|
+
/** Pure: one word from check-run conclusions. No check names, logs or URLs are kept. */
|
|
56
|
+
export declare function rollupCi(runs: Array<{
|
|
57
|
+
status?: unknown;
|
|
58
|
+
conclusion?: unknown;
|
|
59
|
+
}>): CiRollup;
|
|
60
|
+
export declare function isAllowedScanPath(path: string): boolean;
|
|
61
|
+
export declare function createGithubPrSource(deps: GithubPrSourceDeps): PrSource;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// GENERATED from shared/org-scan-core/src/pr-source.ts by cli/scripts/sync-org-scan-core.mjs; do not edit.
|
|
2
|
+
/**
|
|
3
|
+
* Read-only GitHub PR source for the local scan. Port of mnemom-api src/org-scan/scan/github-pr-source.ts
|
|
4
|
+
* (window path, PRs #4208 / #4221 / #4223).
|
|
5
|
+
*
|
|
6
|
+
* METADATA only: size counts, a CI rollup, a revert boolean and the AI tools named in the PR body.
|
|
7
|
+
* Titles and bodies are inspected in memory and dropped; author logins, branch names, and code never
|
|
8
|
+
* leave this module (PrRecord has no such field). Every call goes through `guardedGet`, which only
|
|
9
|
+
* ever issues a GET to api.github.com on an allowlisted path. Failures throw fixed-text errors:
|
|
10
|
+
* never a GitHub body, a header, a URL or the token.
|
|
11
|
+
*/
|
|
12
|
+
import { isValidRepoSlug } from "./credential.js";
|
|
13
|
+
import { detectAiTools } from "./spend/ai-tool-detect.js";
|
|
14
|
+
export const GITHUB_API = "https://api.github.com";
|
|
15
|
+
/** Merged PRs are listed 100 at a time; a list page is one read however many PRs it holds. */
|
|
16
|
+
export const INDEX_PAGE_SIZE = 100;
|
|
17
|
+
/** Pure: one word from check-run conclusions. No check names, logs or URLs are kept. */
|
|
18
|
+
export function rollupCi(runs) {
|
|
19
|
+
if (runs.length === 0)
|
|
20
|
+
return "none";
|
|
21
|
+
if (runs.some((r) => r.status !== "completed"))
|
|
22
|
+
return "pending";
|
|
23
|
+
const ok = new Set(["success", "neutral", "skipped"]);
|
|
24
|
+
return runs.every((r) => typeof r.conclusion === "string" && ok.has(r.conclusion))
|
|
25
|
+
? "success"
|
|
26
|
+
: "failure";
|
|
27
|
+
}
|
|
28
|
+
const isCount = (n) => typeof n === "number" && Number.isInteger(n) && n >= 0;
|
|
29
|
+
const SLUG = "[A-Za-z0-9._-]{1,100}/[A-Za-z0-9._-]{1,100}";
|
|
30
|
+
/** The only paths the scan may read. Anything else is refused before a request is made. */
|
|
31
|
+
const ALLOWED_PATHS = [
|
|
32
|
+
new RegExp(`^/repos/${SLUG}/pulls\\?state=closed&sort=updated&direction=desc&per_page=\\d{1,3}&page=\\d{1,4}$`),
|
|
33
|
+
new RegExp(`^/repos/${SLUG}/pulls/\\d{1,9}$`),
|
|
34
|
+
new RegExp(`^/repos/${SLUG}/commits/[0-9a-f]{40}/check-runs\\?per_page=100$`),
|
|
35
|
+
// The repo picker's one list read (MNE-9643): the repos this login can read, newest push first.
|
|
36
|
+
/^\/user\/repos\?sort=pushed&direction=desc&per_page=\d{1,3}$/,
|
|
37
|
+
];
|
|
38
|
+
export function isAllowedScanPath(path) {
|
|
39
|
+
return ALLOWED_PATHS.some((re) => re.test(path));
|
|
40
|
+
}
|
|
41
|
+
/** The per-read timeout, joined with the caller's signal when there is one. */
|
|
42
|
+
function readSignal(timeoutMs, outer) {
|
|
43
|
+
const t = AbortSignal.timeout(timeoutMs);
|
|
44
|
+
return outer ? AbortSignal.any([outer, t]) : t;
|
|
45
|
+
}
|
|
46
|
+
export function createGithubPrSource(deps) {
|
|
47
|
+
const doFetch = deps.fetchImpl ?? fetch;
|
|
48
|
+
const headers = {
|
|
49
|
+
accept: "application/vnd.github+json",
|
|
50
|
+
"x-github-api-version": "2022-11-28",
|
|
51
|
+
"user-agent": deps.userAgent ?? "mnemom-cli-scan/1",
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* `list` marks the PR list read, the first read of a repo. GitHub hides a repo the credential cannot
|
|
55
|
+
* see behind a 404, so only there is a 404 "repo not visible"; a 404 on a later detail or
|
|
56
|
+
* check-runs read is an ordinary upstream miss and keeps its numeric message (#4223).
|
|
57
|
+
*/
|
|
58
|
+
async function guardedGet(path, repo, list = false) {
|
|
59
|
+
if (!isAllowedScanPath(path))
|
|
60
|
+
throw new Error("scan read not allowed");
|
|
61
|
+
const bearer = await deps.credential.tokenFor(repo);
|
|
62
|
+
const res = await doFetch(`${GITHUB_API}${path}`, {
|
|
63
|
+
method: "GET",
|
|
64
|
+
headers: { ...headers, authorization: `Bearer ${bearer}` },
|
|
65
|
+
redirect: "error",
|
|
66
|
+
signal: readSignal(deps.timeoutMs ?? 10_000, deps.signal),
|
|
67
|
+
});
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
await res.body?.cancel().catch(() => { });
|
|
70
|
+
throw new Error(list && res.status === 404
|
|
71
|
+
? "github repo not visible"
|
|
72
|
+
: `github read failed (${res.status})`);
|
|
73
|
+
}
|
|
74
|
+
return res.json();
|
|
75
|
+
}
|
|
76
|
+
const day = (v) => {
|
|
77
|
+
const t = typeof v === "string" ? Date.parse(v) : NaN;
|
|
78
|
+
return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 10) : null;
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
async indexMerged(repo, cursor) {
|
|
82
|
+
if (!isValidRepoSlug(repo))
|
|
83
|
+
throw new Error("invalid repo");
|
|
84
|
+
const page = cursor === null ? 1 : Number(cursor);
|
|
85
|
+
if (!Number.isInteger(page) || page < 1 || page > 1000)
|
|
86
|
+
throw new Error("invalid cursor");
|
|
87
|
+
const list = (await guardedGet(`/repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=${INDEX_PAGE_SIZE}&page=${page}`, repo, true));
|
|
88
|
+
if (!Array.isArray(list))
|
|
89
|
+
throw new Error("unexpected list shape");
|
|
90
|
+
const items = [];
|
|
91
|
+
for (const it of list) {
|
|
92
|
+
const mergedDay = day(it.merged_at);
|
|
93
|
+
const updatedDay = day(it.updated_at);
|
|
94
|
+
if (mergedDay === null || updatedDay === null || !isCount(it.number))
|
|
95
|
+
continue;
|
|
96
|
+
// The title is read for the revert marker only and dropped here.
|
|
97
|
+
items.push({
|
|
98
|
+
number: it.number,
|
|
99
|
+
mergedDay,
|
|
100
|
+
updatedDay,
|
|
101
|
+
reverted: typeof it.title === "string" && /^revert\b/i.test(it.title),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return { items, next: list.length < INDEX_PAGE_SIZE ? null : String(page + 1), reads: 1 };
|
|
105
|
+
},
|
|
106
|
+
async readPr(repo, item, maxReads = Number.POSITIVE_INFINITY) {
|
|
107
|
+
if (!isValidRepoSlug(repo))
|
|
108
|
+
throw new Error("invalid repo");
|
|
109
|
+
if (!isCount(item.number) || maxReads < 2)
|
|
110
|
+
return { pr: null, reads: 0 };
|
|
111
|
+
const detail = (await guardedGet(`/repos/${repo}/pulls/${item.number}`, repo));
|
|
112
|
+
let reads = 1;
|
|
113
|
+
let ci;
|
|
114
|
+
const sha = detail.head?.sha;
|
|
115
|
+
if (typeof sha === "string" && /^[0-9a-f]{40}$/.test(sha)) {
|
|
116
|
+
const checks = (await guardedGet(`/repos/${repo}/commits/${sha}/check-runs?per_page=100`, repo));
|
|
117
|
+
reads += 1;
|
|
118
|
+
ci = rollupCi(Array.isArray(checks.check_runs) ? checks.check_runs : []);
|
|
119
|
+
}
|
|
120
|
+
if (!isCount(detail.additions) ||
|
|
121
|
+
!isCount(detail.deletions) ||
|
|
122
|
+
!isCount(detail.changed_files))
|
|
123
|
+
return { pr: null, reads };
|
|
124
|
+
const tools = detectAiTools(detail.body);
|
|
125
|
+
return {
|
|
126
|
+
pr: {
|
|
127
|
+
additions: detail.additions,
|
|
128
|
+
deletions: detail.deletions,
|
|
129
|
+
changed_files: detail.changed_files,
|
|
130
|
+
ci,
|
|
131
|
+
reverted: item.reverted,
|
|
132
|
+
tools,
|
|
133
|
+
},
|
|
134
|
+
reads,
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The org-scan report (org-scan-report/1) built from a scan result: the exact body the report email
|
|
3
|
+
* carries. Shared by the CLI (`POST /v1/orgs/{org_id}/scan/report-email`) and the Mnemom-side run
|
|
4
|
+
* (MNE-9752), so both send the same numbers. Pure and in memory: no network, no Node or Worker API.
|
|
5
|
+
*
|
|
6
|
+
* The body is exactly the route's contract and nothing else: `report` (team totals, counts, and the
|
|
7
|
+
* per-repo counts `by_repo`), `share_level`, `coverage.complete` and the optional `emails`. No author,
|
|
8
|
+
* title, branch or body text, no credential.
|
|
9
|
+
*/
|
|
10
|
+
import { type CostPerPr, type CostSpend } from "./cost-per-pr.js";
|
|
11
|
+
import type { ScanResult } from "./scan.js";
|
|
12
|
+
import type { SpendSelection } from "./spend/spend-select.js";
|
|
13
|
+
/** How much of the report Mnemom may keep on file (MNE-9634). The email is always the full report. */
|
|
14
|
+
export type ShareLevel = "full" | "analytics" | "none";
|
|
15
|
+
export declare const REPORT_SCHEMA_VERSION = "org-scan-report/1";
|
|
16
|
+
export declare const ESTIMATE_NAME = "automatable_work_share";
|
|
17
|
+
export declare const METHOD_URL = "https://www.mnemom.ai/org-scan-method";
|
|
18
|
+
export interface EmailReport {
|
|
19
|
+
schema_version: typeof REPORT_SCHEMA_VERSION;
|
|
20
|
+
org_id: string;
|
|
21
|
+
scan_id: string;
|
|
22
|
+
period: {
|
|
23
|
+
start: string;
|
|
24
|
+
end: string;
|
|
25
|
+
};
|
|
26
|
+
generated_at: string;
|
|
27
|
+
measured: {
|
|
28
|
+
teams: [];
|
|
29
|
+
summary: {
|
|
30
|
+
pr_count: number;
|
|
31
|
+
revert_count: number;
|
|
32
|
+
automatable_count: number;
|
|
33
|
+
examined_count: number;
|
|
34
|
+
tokens_total: 0;
|
|
35
|
+
};
|
|
36
|
+
spend: {
|
|
37
|
+
source: "pr_estimate";
|
|
38
|
+
figure?: {
|
|
39
|
+
low_cents: number;
|
|
40
|
+
high_cents: number;
|
|
41
|
+
};
|
|
42
|
+
ai_prs_by_tool?: Array<{
|
|
43
|
+
tool: string;
|
|
44
|
+
count: number;
|
|
45
|
+
}>;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
estimate: {
|
|
49
|
+
name: string;
|
|
50
|
+
value: number;
|
|
51
|
+
unit: string;
|
|
52
|
+
method_url: string;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Optional, added after org-scan-report/1 shipped; absent whenever it cannot be computed (no spend,
|
|
56
|
+
* no merged PR in the period, an incomplete scan, an empty bucket). See cost-per-pr.ts.
|
|
57
|
+
* cost_per_merged_pr average cost of one merged PR in the period
|
|
58
|
+
* cost_by_pr_size { small | medium | large }: average cost of ONE PR of that size, per-bucket
|
|
59
|
+
* keys absent when the bucket is empty. small < 50 lines changed, medium 50..500, large > 500.
|
|
60
|
+
* A figure is { label: "estimate", spend_source, pr_scope, pr_count } (+ spend_scope: "whole_org" on
|
|
61
|
+
* anthropic_usage) plus { value_cents } (anthropic_usage) or { low_cents, high_cents } (modelled_range). Integer cents.
|
|
62
|
+
* pr_scope is "ai_assisted" for modelled_range (spend and size buckets split across AI-assisted PRs only;
|
|
63
|
+
* absent if none) and "all_merged" otherwise; pr_count counts PRs in that scope.
|
|
64
|
+
* Always attached when present. The API's report validator (mnemom-api src/org-scan/report-schema.ts) is
|
|
65
|
+
* closed, so an API that does not yet list these keys answers 400 invalid_report; sendReportEmail then
|
|
66
|
+
* retries once without them so the report email still goes out.
|
|
67
|
+
*/
|
|
68
|
+
cost_per_merged_pr?: CostPerPr["cost_per_merged_pr"];
|
|
69
|
+
cost_by_pr_size?: CostPerPr["cost_by_pr_size"];
|
|
70
|
+
/**
|
|
71
|
+
* The per-repo counts, by name (MNE-9634). Always sent, at every share level: the emailed report is the full
|
|
72
|
+
* report. Optional on the wire so an API that predates it can be retried without it. Capped at 500 repos
|
|
73
|
+
* (busiest first), valid names only, each name once.
|
|
74
|
+
*/
|
|
75
|
+
by_repo?: Array<{
|
|
76
|
+
repo: string;
|
|
77
|
+
pr_count: number;
|
|
78
|
+
revert_count: number;
|
|
79
|
+
automatable_count: number;
|
|
80
|
+
examined_count: number;
|
|
81
|
+
}>;
|
|
82
|
+
}
|
|
83
|
+
export interface ReportEmailBody {
|
|
84
|
+
report: EmailReport;
|
|
85
|
+
share_level: ShareLevel;
|
|
86
|
+
coverage: {
|
|
87
|
+
complete: boolean;
|
|
88
|
+
};
|
|
89
|
+
/** The extra recipients the runner added. Absent when none. */
|
|
90
|
+
emails?: string[];
|
|
91
|
+
/** Only set on the retry to an API that predates `emails`: it reads one extra address under this name. */
|
|
92
|
+
cto_email?: string;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Maps the local scan onto the route's closed report shape. Team totals stay empty (they come only from
|
|
96
|
+
* Anthropic usage, which the local scan does not carry). The estimate is the share of examined merged
|
|
97
|
+
* PRs that finished with every CI check passing. `figure` (tier 3 range) is included only when the CLI
|
|
98
|
+
* itself would let that line leave the machine (`sendSpendRange`).
|
|
99
|
+
*/
|
|
100
|
+
export declare function buildEmailReport(result: ScanResult, o: {
|
|
101
|
+
orgId: string;
|
|
102
|
+
now: () => number;
|
|
103
|
+
sendSpendRange: boolean;
|
|
104
|
+
scanId?: string;
|
|
105
|
+
/** The requested scan period (the one the spend covers), not the examined window. */
|
|
106
|
+
period?: {
|
|
107
|
+
start: string;
|
|
108
|
+
end: string;
|
|
109
|
+
};
|
|
110
|
+
/** The one spend source the cost figures are split from; null/absent leaves them out. */
|
|
111
|
+
costSpend?: CostSpend | null;
|
|
112
|
+
}): EmailReport;
|
|
113
|
+
export declare function buildEmailBody(report: EmailReport, level: ShareLevel, complete: boolean, extraEmails?: string[]): ReportEmailBody;
|
|
114
|
+
/**
|
|
115
|
+
* The one spend source the cost-per-PR figures are split from, given the spend line selected (null when
|
|
116
|
+
* the spend tiers are off) and the measured Anthropic usage in dollars, if any.
|
|
117
|
+
*/
|
|
118
|
+
export declare function costSpendFor(result: ScanResult, spend: SpendSelection | null, measuredUsd: number | undefined): CostSpend | null;
|
|
119
|
+
/** May the tier 3 range leave the machine in the report? Only when it is the tier shown (or tiers are off). */
|
|
120
|
+
export declare const sendsSpendRange: (spend: SpendSelection | null) => boolean;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// GENERATED from shared/org-scan-core/src/report-build.ts by cli/scripts/sync-org-scan-core.mjs; do not edit.
|
|
2
|
+
/**
|
|
3
|
+
* The org-scan report (org-scan-report/1) built from a scan result: the exact body the report email
|
|
4
|
+
* carries. Shared by the CLI (`POST /v1/orgs/{org_id}/scan/report-email`) and the Mnemom-side run
|
|
5
|
+
* (MNE-9752), so both send the same numbers. Pure and in memory: no network, no Node or Worker API.
|
|
6
|
+
*
|
|
7
|
+
* The body is exactly the route's contract and nothing else: `report` (team totals, counts, and the
|
|
8
|
+
* per-repo counts `by_repo`), `share_level`, `coverage.complete` and the optional `emails`. No author,
|
|
9
|
+
* title, branch or body text, no credential.
|
|
10
|
+
*/
|
|
11
|
+
import { buildCostPerPr, modelledSpend } from "./cost-per-pr.js";
|
|
12
|
+
export const REPORT_SCHEMA_VERSION = "org-scan-report/1";
|
|
13
|
+
export const ESTIMATE_NAME = "automatable_work_share";
|
|
14
|
+
export const METHOD_URL = "https://www.mnemom.ai/org-scan-method";
|
|
15
|
+
const cents = (usd) => Math.max(0, Math.round(usd * 100));
|
|
16
|
+
/**
|
|
17
|
+
* Maps the local scan onto the route's closed report shape. Team totals stay empty (they come only from
|
|
18
|
+
* Anthropic usage, which the local scan does not carry). The estimate is the share of examined merged
|
|
19
|
+
* PRs that finished with every CI check passing. `figure` (tier 3 range) is included only when the CLI
|
|
20
|
+
* itself would let that line leave the machine (`sendSpendRange`).
|
|
21
|
+
*/
|
|
22
|
+
export function buildEmailReport(result, o) {
|
|
23
|
+
const { totals, coverage } = result;
|
|
24
|
+
const value = totals.examined > 0 ? Math.round((totals.ciGreen / totals.examined) * 1000) / 10 : 0;
|
|
25
|
+
const scope = coverage.complete
|
|
26
|
+
? "every merged pull request in the period"
|
|
27
|
+
: "a capped sample (not every merged pull request in the period)";
|
|
28
|
+
const unit = `percent of merged pull requests examined (${totals.examined} of ${totals.prs}, ${scope}) ` +
|
|
29
|
+
`that finished with all CI checks passing; an estimate from pull request metadata, not a measured share`;
|
|
30
|
+
const byTool = result.spend.kind === "range"
|
|
31
|
+
? Object.entries(result.spend.byTool)
|
|
32
|
+
.filter(([, n]) => typeof n === "number" && n > 0)
|
|
33
|
+
.map(([tool, count]) => ({ tool, count: count }))
|
|
34
|
+
: [];
|
|
35
|
+
// Only a scan that read every merged PR in the period gives an average that means anything.
|
|
36
|
+
const exhaustive = coverage.complete && coverage.prsSkippedLarge === 0 && coverage.prsUnreadable === 0;
|
|
37
|
+
const cost = buildCostPerPr(result.prSizes, o.period ?? { start: coverage.windowStart, end: coverage.windowEnd }, o.costSpend ?? null, exhaustive);
|
|
38
|
+
const byRepo = repoRows(result);
|
|
39
|
+
return {
|
|
40
|
+
schema_version: REPORT_SCHEMA_VERSION,
|
|
41
|
+
org_id: o.orgId,
|
|
42
|
+
scan_id: o.scanId ?? crypto.randomUUID(),
|
|
43
|
+
// The span the scan actually examined (coverage), never the requested period: with no --period
|
|
44
|
+
// that defaults to today..today, which is not what was read.
|
|
45
|
+
period: { start: coverage.windowStart, end: coverage.windowEnd },
|
|
46
|
+
generated_at: new Date(o.now()).toISOString(),
|
|
47
|
+
measured: {
|
|
48
|
+
teams: [],
|
|
49
|
+
summary: {
|
|
50
|
+
pr_count: totals.prs,
|
|
51
|
+
revert_count: totals.reverts,
|
|
52
|
+
automatable_count: totals.ciGreen,
|
|
53
|
+
examined_count: totals.examined,
|
|
54
|
+
tokens_total: 0,
|
|
55
|
+
},
|
|
56
|
+
spend: {
|
|
57
|
+
source: "pr_estimate",
|
|
58
|
+
...(o.sendSpendRange && result.spend.kind === "range"
|
|
59
|
+
? {
|
|
60
|
+
figure: {
|
|
61
|
+
low_cents: cents(result.spend.lowUsd),
|
|
62
|
+
high_cents: cents(result.spend.highUsd),
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
: {}),
|
|
66
|
+
...(o.sendSpendRange && byTool.length > 0 ? { ai_prs_by_tool: byTool } : {}),
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
estimate: { name: ESTIMATE_NAME, value, unit, method_url: METHOD_URL },
|
|
70
|
+
...cost,
|
|
71
|
+
...(byRepo.length > 0 ? { by_repo: byRepo } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const MAX_REPOS = 500;
|
|
75
|
+
const REPO_NAME = /^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/;
|
|
76
|
+
/** The per-repo rows for the report: valid names only, each once (any case), busiest first, at most 500. */
|
|
77
|
+
function repoRows(result) {
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
return [...result.byRepo]
|
|
80
|
+
.sort((a, b) => b.prs - a.prs || a.repo.localeCompare(b.repo))
|
|
81
|
+
.filter((r) => {
|
|
82
|
+
const k = r.repo.toLowerCase();
|
|
83
|
+
if (!REPO_NAME.test(r.repo) || seen.has(k))
|
|
84
|
+
return false;
|
|
85
|
+
seen.add(k);
|
|
86
|
+
return true;
|
|
87
|
+
})
|
|
88
|
+
.slice(0, MAX_REPOS)
|
|
89
|
+
.map((r) => ({
|
|
90
|
+
repo: r.repo,
|
|
91
|
+
pr_count: r.prs,
|
|
92
|
+
revert_count: r.reverts,
|
|
93
|
+
automatable_count: r.ciGreen,
|
|
94
|
+
examined_count: r.examined,
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
export function buildEmailBody(report, level, complete, extraEmails = []) {
|
|
98
|
+
return {
|
|
99
|
+
report,
|
|
100
|
+
share_level: level,
|
|
101
|
+
coverage: { complete },
|
|
102
|
+
...(extraEmails.length > 0 ? { emails: extraEmails } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The one spend source the cost-per-PR figures are split from, given the spend line selected (null when
|
|
107
|
+
* the spend tiers are off) and the measured Anthropic usage in dollars, if any.
|
|
108
|
+
*/
|
|
109
|
+
export function costSpendFor(result, spend, measuredUsd) {
|
|
110
|
+
if (spend === null)
|
|
111
|
+
return modelledSpend(result.spend);
|
|
112
|
+
if (spend.tier === 1)
|
|
113
|
+
return typeof measuredUsd === "number" ? { source: "anthropic_usage", usd: measuredUsd } : null;
|
|
114
|
+
if (spend.tier === 2)
|
|
115
|
+
return null;
|
|
116
|
+
return spend.tier === 3 ? modelledSpend(result.spend) : null;
|
|
117
|
+
}
|
|
118
|
+
/** May the tier 3 range leave the machine in the report? Only when it is the tier shown (or tiers are off). */
|
|
119
|
+
export const sendsSpendRange = (spend) => spend ? spend.shareable && spend.tier === 3 : true;
|